From 0c04f7516bc7dd52244a9b88b4a628a217c3355c Mon Sep 17 00:00:00 2001 From: yuanyuanxiang <962914132@qq.com> Date: Sat, 17 Oct 2026 19:52:38 +0200 Subject: [PATCH] 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 --- server/2015Remote/ScreenSpyDlg.cpp | 4 ++ server/2015Remote/WebService.cpp | 111 +++++++++++++++++++++++++++++ server/2015Remote/WebService.h | 5 ++ server/go/cmd/main.go | 14 ++++ server/go/hub/hub.go | 11 +++ server/go/hub/hub_test.go | 2 + server/go/protocol/commands.go | 21 +++--- server/go/web/ws.go | 23 +++++- server/go/web/ws_handlers.go | 58 +++++++++++++++ server/web/index.html | 53 ++++++++++++++ 10 files changed, 290 insertions(+), 12 deletions(-) diff --git a/server/2015Remote/ScreenSpyDlg.cpp b/server/2015Remote/ScreenSpyDlg.cpp index 9d93e20..a1b0b73 100644 --- a/server/2015Remote/ScreenSpyDlg.cpp +++ b/server/2015Remote/ScreenSpyDlg.cpp @@ -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: { diff --git a/server/2015Remote/WebService.cpp b/server/2015Remote/WebService.cpp index c58abc6..1238f4f 100644 --- a/server/2015Remote/WebService.cpp +++ b/server/2015Remote/WebService.cpp @@ -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 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 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 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 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; diff --git a/server/2015Remote/WebService.h b/server/2015Remote/WebService.h index cb4c207..4488767 100644 --- a/server/2015Remote/WebService.h +++ b/server/2015Remote/WebService.h @@ -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); diff --git a/server/go/cmd/main.go b/server/go/cmd/main.go index 43cc634..84588de 100644 --- a/server/go/cmd/main.go +++ b/server/go/cmd/main.go @@ -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) diff --git a/server/go/hub/hub.go b/server/go/hub/hub.go index fae62df..52d62e3 100644 --- a/server/go/hub/hub.go +++ b/server/go/hub/hub.go @@ -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 diff --git a/server/go/hub/hub_test.go b/server/go/hub/hub_test.go index 38fc4dd..56a6590 100644 --- a/server/go/hub/hub_test.go +++ b/server/go/hub/hub_test.go @@ -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) {} diff --git a/server/go/protocol/commands.go b/server/go/protocol/commands.go index ca1d2db..5cc2829 100644 --- a/server/go/protocol/commands.go +++ b/server/go/protocol/commands.go @@ -103,15 +103,17 @@ func DecodeClientString(data []byte, capability, clientType string) string { // Command tokens - matching the C++ definitions (common/commands.h). const ( // Server -> Client commands - 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) - 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] - CmdRestoreConsole byte = 82 // CMD_RESTORE_CONSOLE - RDP session "归位": switch back to the console session and restart capture - CommandBye byte = 204 // COMMAND_BYE - disconnect - CommandHeartbeat byte = 216 // CMD_HEARTBEAT_ACK + 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] + CmdRestoreConsole byte = 82 // CMD_RESTORE_CONSOLE - RDP session "归位": switch back to the console session and restart capture + CommandBye byte = 204 // COMMAND_BYE - disconnect + CommandHeartbeat byte = 216 // CMD_HEARTBEAT_ACK // Client -> Server tokens TokenAuth byte = 100 // TOKEN_AUTH - authorization required @@ -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 diff --git a/server/go/web/ws.go b/server/go/web/ws.go index 5c2dedc..048730e 100644 --- a/server/go/web/ws.go +++ b/server/go/web/ws.go @@ -114,9 +114,9 @@ type wsHub struct { func newWSHub(auth *wsauth.Authenticator, devices *hub.Hub, log *logger.Logger) *wsHub { h := &wsHub{ - auth: auth, - devices: devices, - log: log, + auth: auth, + devices: devices, + log: log, clients: make(map[*wsClient]struct{}), screenCloseTimers: make(map[string]*time.Timer), } @@ -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 diff --git a/server/go/web/ws_handlers.go b/server/go/web/ws_handlers.go index 3cefc17..435aca3 100644 --- a/server/go/web/ws_handlers.go +++ b/server/go/web/ws_handlers.go @@ -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 diff --git a/server/web/index.html b/server/web/index.html index 63631d1..a87c825 100644 --- a/server/web/index.html +++ b/server/web/index.html @@ -1365,6 +1365,7 @@