Fix inbound clipboard sync from RustDesk to rdclient web.

Decode zstd-compressed clipboard payloads, handle multi_clipboards, and write text/HTML/image formats to the browser clipboard.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
UNITRONIX
2026-06-30 20:26:17 +02:00
parent 516bcfd70f
commit 515bb1218d
7 changed files with 574 additions and 33 deletions
+35 -14
View File
@@ -13,7 +13,7 @@
* client.disconnect();
*/
/* global RDConnection, RDProtocol, RDCrypto, RDVideo, RDAudio, RDRenderer, RDInput, RDFileConnection */
/* global RDConnection, RDProtocol, RDCrypto, RDVideo, RDAudio, RDRenderer, RDInput, RDFileConnection, RDClipboard */
// eslint-disable-next-line no-unused-vars
class RDClient {
@@ -776,12 +776,18 @@ class RDClient {
return;
}
// Clipboard
// Clipboard (legacy single entry)
if (msg.clipboard) {
this._handleClipboard(msg.clipboard);
return;
}
// Multi-format clipboard (RustDesk >= 1.3.0)
if (msg.multiClipboards) {
this._handleMultiClipboards(msg.multiClipboards);
return;
}
// Test delay (ping/pong)
if (msg.testDelay) {
this._handleTestDelay(msg.testDelay);
@@ -1046,19 +1052,30 @@ class RDClient {
}
}
_handleClipboard(clipboard) {
if (clipboard.content) {
const decoder = new TextDecoder();
const text = decoder.decode(clipboard.content);
this._emit('clipboard', text);
async _applyRemoteClipboard(clipboards) {
const list = clipboards || [];
if (!list.length) return;
// Copy to local clipboard only for the active viewer tab
if (this._clipboardToLocalEnabled && navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).catch(() => {
// Clipboard write permission denied - ignore
});
}
const decoded = await RDClipboard.decodeEntries(list);
const text = RDClipboard.pickBestText(decoded);
if (text) {
this._emit('clipboard', text);
}
await RDClipboard.applyToLocal(decoded, {
enabled: this._clipboardToLocalEnabled
});
}
_handleClipboard(clipboard) {
void this._applyRemoteClipboard([clipboard]);
}
_handleMultiClipboards(multiClipboards) {
const list = multiClipboards && multiClipboards.clipboards
? multiClipboards.clipboards
: [];
void this._applyRemoteClipboard(list);
}
_handleTestDelay(testDelay) {
@@ -1411,7 +1428,11 @@ class RDClient {
*/
sendClipboard(text) {
if (this._state !== 'streaming') return;
const msg = this.proto.buildClipboard(text);
void this._sendClipboard(text);
}
async _sendClipboard(text) {
const msg = await this.proto.buildClipboard(text);
this._sendPeerMessage(msg);
}
+283
View File
@@ -0,0 +1,283 @@
/**
* BetterDesk Web Remote Client - inbound clipboard decode/apply
* Handles RustDesk Clipboard / MultiClipboards (zstd, text/html/image).
*/
/* global RDCompress */
// eslint-disable-next-line no-unused-vars
class RDClipboard {
static OWNER_FORMAT = 'dyn.com.rustdesk.owner';
static FORMAT = {
Text: 0,
Rtf: 1,
Html: 2,
ImageRgba: 21,
ImagePng: 22,
ImageSvg: 23,
Special: 31
};
/**
* @param {number|string} format
* @returns {number}
*/
static formatId(format) {
if (format == null) return RDClipboard.FORMAT.Text;
if (typeof format === 'number') return format;
const map = {
Text: RDClipboard.FORMAT.Text,
Rtf: RDClipboard.FORMAT.Rtf,
Html: RDClipboard.FORMAT.Html,
ImageRgba: RDClipboard.FORMAT.ImageRgba,
ImagePng: RDClipboard.FORMAT.ImagePng,
ImageSvg: RDClipboard.FORMAT.ImageSvg,
Special: RDClipboard.FORMAT.Special
};
return map[format] != null ? map[format] : RDClipboard.FORMAT.Text;
}
/**
* @param {Object} clipboard
* @returns {boolean}
*/
static shouldSkipEntry(clipboard) {
if (!clipboard) return true;
const fmt = RDClipboard.formatId(clipboard.format);
if (fmt !== RDClipboard.FORMAT.Special) return false;
const name = clipboard.specialName || clipboard.special_name || '';
return name === RDClipboard.OWNER_FORMAT;
}
/**
* @param {*} content
* @returns {Uint8Array|null}
*/
static normalizeContent(content) {
if (!content) return null;
if (content instanceof Uint8Array) return content;
if (content instanceof ArrayBuffer) return new Uint8Array(content);
if (content.length != null) return new Uint8Array(content);
return null;
}
/**
* @param {string} html
* @returns {string}
*/
static stripHtml(html) {
if (!html) return '';
if (typeof DOMParser !== 'undefined') {
try {
const doc = new DOMParser().parseFromString(html, 'text/html');
return (doc.body && doc.body.textContent) ? doc.body.textContent : '';
} catch (_) {
// fall through to regex
}
}
return html
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<[^>]+>/g, '')
.replace(/\s+/g, ' ')
.trim();
}
/**
* @param {string} rtf
* @returns {string}
*/
static extractRtfPlain(rtf) {
if (!rtf) return '';
const plainMatch = rtf.match(/\\plain[\s\S]*?(?=\\par|\\cell|\\row|\\}|$)/i);
if (plainMatch) {
return plainMatch[0]
.replace(/^\\plain\s*/i, '')
.replace(/\\[a-z]+\d* ?/gi, '')
.replace(/[{}]/g, '')
.replace(/\s+/g, ' ')
.trim();
}
return rtf
.replace(/\\[a-z]+\d* ?/gi, '')
.replace(/[{}]/g, '')
.replace(/\s+/g, ' ')
.trim();
}
/**
* @param {number} width
* @param {number} height
* @param {Uint8Array} rgbaBytes
* @returns {Promise<Blob|null>}
*/
static rgbaToPngBlob(width, height, rgbaBytes) {
const w = Number(width) || 0;
const h = Number(height) || 0;
const expected = w * h * 4;
if (!w || !h || rgbaBytes.length < expected) {
return Promise.resolve(null);
}
return new Promise((resolve) => {
try {
const canvas = (typeof OffscreenCanvas !== 'undefined')
? new OffscreenCanvas(w, h)
: document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
if (!ctx) {
resolve(null);
return;
}
const slice = rgbaBytes.subarray(0, expected);
const imgData = new ImageData(new Uint8ClampedArray(slice), w, h);
ctx.putImageData(imgData, 0, 0);
if (canvas.convertToBlob) {
canvas.convertToBlob({ type: 'image/png' }).then(resolve).catch(() => resolve(null));
return;
}
canvas.toBlob((blob) => resolve(blob), 'image/png');
} catch (_) {
resolve(null);
}
});
}
/**
* @param {Object} clipboard - protobuf Clipboard
* @returns {Promise<Object|null>}
*/
static async decodeEntry(clipboard) {
if (!clipboard || RDClipboard.shouldSkipEntry(clipboard)) return null;
let bytes = RDClipboard.normalizeContent(clipboard.content);
if (!bytes || !bytes.length) return null;
const needsDecompress = !!clipboard.compress || RDCompress.isZstdMagic(bytes);
if (needsDecompress) {
bytes = await RDCompress.decompressZstd(bytes, { force: true });
}
const fmt = RDClipboard.formatId(clipboard.format);
const decoder = new TextDecoder('utf-8', { fatal: false });
switch (fmt) {
case RDClipboard.FORMAT.Text:
return { format: 'text', text: decoder.decode(bytes) };
case RDClipboard.FORMAT.Html: {
const html = decoder.decode(bytes);
return { format: 'html', html, text: RDClipboard.stripHtml(html) };
}
case RDClipboard.FORMAT.Rtf: {
const rtf = decoder.decode(bytes);
return { format: 'rtf', text: RDClipboard.extractRtfPlain(rtf) };
}
case RDClipboard.FORMAT.ImagePng:
return { format: 'image/png', pngBlob: new Blob([bytes], { type: 'image/png' }) };
case RDClipboard.FORMAT.ImageRgba: {
const blob = await RDClipboard.rgbaToPngBlob(clipboard.width, clipboard.height, bytes);
if (!blob) return null;
return { format: 'image/png', pngBlob: blob };
}
case RDClipboard.FORMAT.ImageSvg: {
const svg = decoder.decode(bytes);
return { format: 'image/svg+xml', svgText: svg, text: svg };
}
default:
return null;
}
}
/**
* @param {Object[]} entries
* @returns {string}
*/
static pickBestText(entries) {
for (let i = 0; i < entries.length; i++) {
const e = entries[i];
if (e && e.format === 'text' && e.text) return e.text;
}
for (let i = 0; i < entries.length; i++) {
const e = entries[i];
if (e && e.format === 'html' && e.text) return e.text;
}
for (let i = 0; i < entries.length; i++) {
const e = entries[i];
if (e && e.format === 'rtf' && e.text) return e.text;
}
for (let i = 0; i < entries.length; i++) {
const e = entries[i];
if (e && e.format === 'image/svg+xml' && e.text) return e.text;
}
return '';
}
/**
* @param {Object[]} decodedEntries
* @param {Object} [opts]
* @param {boolean} [opts.enabled]
* @returns {Promise<void>}
*/
static async applyToLocal(decodedEntries, opts) {
const options = opts || {};
if (!options.enabled) return;
if (!navigator.clipboard) return;
const entries = (decodedEntries || []).filter(Boolean);
if (!entries.length) return;
const text = RDClipboard.pickBestText(entries);
const htmlEntry = entries.find((e) => e.format === 'html' && e.html);
const pngEntry = entries.find((e) => e.pngBlob);
const svgEntry = entries.find((e) => e.format === 'image/svg+xml' && e.svgText);
try {
if (pngEntry && navigator.clipboard.write && typeof ClipboardItem !== 'undefined') {
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngEntry.pngBlob })]);
return;
}
if (svgEntry && navigator.clipboard.write && typeof ClipboardItem !== 'undefined') {
const blob = new Blob([svgEntry.svgText], { type: 'image/svg+xml' });
await navigator.clipboard.write([new ClipboardItem({ 'image/svg+xml': blob })]);
return;
}
if (htmlEntry && navigator.clipboard.write && typeof ClipboardItem !== 'undefined') {
const plain = htmlEntry.text || RDClipboard.stripHtml(htmlEntry.html);
await navigator.clipboard.write([new ClipboardItem({
'text/html': new Blob([htmlEntry.html], { type: 'text/html' }),
'text/plain': new Blob([plain], { type: 'text/plain' })
})]);
return;
}
if (text && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
}
} catch (err) {
if (text && navigator.clipboard.writeText) {
try {
await navigator.clipboard.writeText(text);
} catch (_) {
// permission denied — ignore
}
}
}
}
/**
* @param {Object[]} clipboards
* @returns {Promise<Object[]>}
*/
static async decodeEntries(clipboards) {
const list = clipboards || [];
const out = [];
for (let i = 0; i < list.length; i++) {
const decoded = await RDClipboard.decodeEntry(list[i]);
if (decoded) out.push(decoded);
}
return out;
}
}
+96
View File
@@ -0,0 +1,96 @@
/**
* BetterDesk Web Remote Client - zstd compression helpers
* Shared by file transfer and clipboard sync (RustDesk wire format).
*/
// eslint-disable-next-line no-unused-vars
class RDCompress {
/**
* @param {Uint8Array|ArrayBuffer|Array} data
* @returns {Uint8Array}
*/
static normalizeBytes(data) {
if (data instanceof Uint8Array) return data;
if (data instanceof ArrayBuffer) return new Uint8Array(data);
if (data && data.length != null) return new Uint8Array(data);
return new Uint8Array(0);
}
/**
* @param {Uint8Array} bytes
* @returns {boolean}
*/
static isZstdMagic(bytes) {
return bytes.length >= 4
&& bytes[0] === 0x28
&& bytes[1] === 0xb5
&& bytes[2] === 0x2f
&& bytes[3] === 0xfd;
}
/**
* Decompress RustDesk zstd payload.
* @param {Uint8Array|ArrayBuffer|Array} data
* @param {Object} [opts]
* @param {boolean} [opts.force] - decompress even without magic bytes
* @returns {Promise<Uint8Array>}
*/
static async decompressZstd(data, opts) {
const options = opts || {};
const bytes = RDCompress.normalizeBytes(data);
if (!bytes.length) return bytes;
const force = !!options.force;
if (!force && !RDCompress.isZstdMagic(bytes)) {
return bytes;
}
if (typeof DecompressionStream === 'function') {
try {
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('zstd'));
const out = await new Response(stream).arrayBuffer();
return new Uint8Array(out);
} catch (err) {
console.warn('[RDCompress] DecompressionStream zstd failed:', err.message || err);
}
}
const decoder = (typeof window !== 'undefined' && window._zstdDecoder)
|| (typeof globalThis !== 'undefined' && globalThis._zstdDecoder);
if (decoder && typeof decoder.decode === 'function') {
try {
const max = Math.min(Math.max(bytes.length * 30, 1024 * 1024), 64 * 1024 * 1024);
return decoder.decode(bytes, max);
} catch (err) {
console.warn('[RDCompress] zstddec fallback failed:', err.message || err);
}
}
return bytes;
}
/**
* Compress bytes with zstd when beneficial (RustDesk clipboard/file parity).
* @param {Uint8Array} bytes
* @returns {Promise<{ content: Uint8Array, compress: boolean }>}
*/
static async compressZstd(bytes) {
const raw = RDCompress.normalizeBytes(bytes);
if (!raw.length) {
return { content: raw, compress: false };
}
if (typeof CompressionStream !== 'function' || raw.length <= 128) {
return { content: raw, compress: false };
}
try {
const stream = new Blob([raw]).stream().pipeThrough(new CompressionStream('zstd'));
const compressed = new Uint8Array(await new Response(stream).arrayBuffer());
if (compressed.length < raw.length) {
return { content: compressed, compress: true };
}
} catch (_) {
// fall back to raw payload
}
return { content: raw, compress: false };
}
}
+2 -12
View File
@@ -10,7 +10,7 @@
* Cancel: FileAction.cancel
*/
/* global RDProtocol */
/* global RDProtocol, RDCompress */
// eslint-disable-next-line no-unused-vars
class RDFileTransfer {
@@ -608,17 +608,7 @@ class RDFileTransfer {
* @returns {Promise<Uint8Array>}
*/
async _decompressBlock(data) {
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
if (typeof DecompressionStream === 'function') {
try {
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('zstd'));
const out = await new Response(stream).arrayBuffer();
return new Uint8Array(out);
} catch (err) {
console.warn('[FileTransfer] zstd decompress failed, using raw block:', err.message || err);
}
}
return bytes;
return RDCompress.decompressZstd(data, { force: true });
}
/**
+13 -7
View File
@@ -169,7 +169,7 @@ class RDProtocol {
licenceKey: serverKey || '',
connType: ct,
token: '',
version: 'BetterDesk-Web/1.0',
version: 'BetterDesk-Web/1.3.9',
forceRelay: true // Browser must use relay
}
};
@@ -238,7 +238,7 @@ class RDProtocol {
myId: opts.myId || 'web-client-ft',
myName: opts.myName || 'BetterDesk Web',
myPlatform: 'Web',
version: 'BetterDesk-Web/1.0',
version: 'BetterDesk-Web/1.3.9',
sessionId: Date.now(),
fileTransfer: {
dir: opts.dir != null ? opts.dir : '',
@@ -263,7 +263,7 @@ class RDProtocol {
myId: opts.myId || 'web-client',
myName: opts.myName || 'BetterDesk Web',
myPlatform: 'Web',
version: 'BetterDesk-Web/1.0',
version: 'BetterDesk-Web/1.3.9',
sessionId: Date.now(),
option: {
imageQuality: this.enums.ImageQuality.values[quality] || this.enums.ImageQuality.values.Best,
@@ -352,14 +352,20 @@ class RDProtocol {
}
/**
* Build Clipboard message
* Build Clipboard message (optional zstd compression for large payloads)
* @param {string} text
* @returns {Promise<Object>}
*/
buildClipboard(text) {
async buildClipboard(text) {
const encoder = new TextEncoder();
const raw = encoder.encode(text || '');
const packed = (typeof RDCompress !== 'undefined')
? await RDCompress.compressZstd(raw)
: { content: raw, compress: false };
return {
clipboard: {
compress: false,
content: encoder.encode(text),
compress: packed.compress,
content: packed.content,
format: this.enums.ClipboardFormat.values.Text
}
};
+143
View File
@@ -0,0 +1,143 @@
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
function loadRdclientModules() {
const sandbox = {
console,
TextDecoder,
TextEncoder,
Uint8Array,
Blob: typeof Blob !== 'undefined' ? Blob : undefined,
DecompressionStream: typeof DecompressionStream !== 'undefined' ? DecompressionStream : undefined,
CompressionStream: typeof CompressionStream !== 'undefined' ? CompressionStream : undefined,
Response: typeof Response !== 'undefined' ? Response : undefined,
navigator: { clipboard: { writeText: jest.fn(), write: jest.fn() } },
window: {},
globalThis: {},
};
sandbox.window = sandbox;
sandbox.globalThis = sandbox;
const base = path.join(__dirname, '..', 'public/js/rdclient');
vm.runInNewContext(fs.readFileSync(path.join(base, 'compress.js'), 'utf8'), sandbox, {
filename: 'compress.js'
});
vm.runInNewContext(fs.readFileSync(path.join(base, 'clipboard.js'), 'utf8'), sandbox, {
filename: 'clipboard.js'
});
return sandbox;
}
describe('RDClipboard helpers', () => {
let RDClipboard;
let RDCompress;
beforeAll(() => {
const sandbox = loadRdclientModules();
RDClipboard = sandbox.RDClipboard;
RDCompress = sandbox.RDCompress;
});
it('skips RustDesk owner special format', () => {
expect(RDClipboard.shouldSkipEntry({
format: 'Special',
specialName: 'dyn.com.rustdesk.owner',
content: new Uint8Array([1, 2, 3])
})).toBe(true);
expect(RDClipboard.shouldSkipEntry({
format: 31,
special_name: 'XML Spreadsheet',
content: new Uint8Array([1])
})).toBe(false);
});
it('stripHtml removes tags and keeps text', () => {
expect(RDClipboard.stripHtml('<p>Hello <b>world</b></p>')).toBe('Hello world');
});
it('pickBestText prefers Text over Html and Rtf', () => {
const entries = [
{ format: 'html', text: 'html plain', html: '<i>x</i>' },
{ format: 'text', text: 'plain text' },
{ format: 'rtf', text: 'rtf plain' }
];
expect(RDClipboard.pickBestText(entries)).toBe('plain text');
expect(RDClipboard.pickBestText([
{ format: 'html', text: 'from html', html: '<b>x</b>' },
{ format: 'rtf', text: 'from rtf' }
])).toBe('from html');
});
it('decodeEntry returns UTF-8 text when not compressed', async () => {
const text = 'Remote clipboard line';
const decoded = await RDClipboard.decodeEntry({
compress: false,
format: 'Text',
content: new TextEncoder().encode(text)
});
expect(decoded).toEqual({ format: 'text', text });
});
it('decodeEntries ignores owner special entries', async () => {
const decoded = await RDClipboard.decodeEntries([
{
compress: false,
format: 'Special',
specialName: 'dyn.com.rustdesk.owner',
content: new Uint8Array([9, 9, 9])
},
{
compress: false,
format: 'Text',
content: new TextEncoder().encode('kept')
}
]);
expect(decoded).toHaveLength(1);
expect(decoded[0].text).toBe('kept');
});
});
describe('RDCompress helpers', () => {
let RDCompress;
beforeAll(() => {
RDCompress = loadRdclientModules().RDCompress;
});
it('detects zstd magic bytes', () => {
expect(RDCompress.isZstdMagic(new Uint8Array([0x28, 0xb5, 0x2f, 0xfd, 0x00]))).toBe(true);
expect(RDCompress.isZstdMagic(new Uint8Array([1, 2, 3, 4]))).toBe(false);
});
it('returns raw bytes when not compressed and force is false', async () => {
const raw = new Uint8Array([72, 105]);
const out = await RDCompress.decompressZstd(raw);
expect(Array.from(out)).toEqual([72, 105]);
});
});
describe('RDClient clipboard protobuf field', () => {
const protobuf = require('protobufjs');
it('round-trips multi_clipboards on Message', async () => {
const root = await protobuf.load([
path.join(__dirname, '../protos/message.proto')
]);
const Message = root.lookupType('hbb.Message');
const msg = Message.create({
multiClipboards: {
clipboards: [{
compress: false,
format: 0,
content: Buffer.from('hello', 'utf8')
}]
}
});
const decoded = Message.decode(Message.encode(msg).finish());
expect(decoded.multiClipboards.clipboards).toHaveLength(1);
expect(Buffer.from(decoded.multiClipboards.clipboards[0].content).toString('utf8')).toBe('hello');
});
});
+2
View File
@@ -60,6 +60,8 @@
<script src="/js/rdclient/file-connection.js?v=<%= cacheVersion %>"></script>
<script src="/js/rdclient/local-files.js?v=<%= cacheVersion %>"></script>
<script src="/js/rdclient/file-modal.js?v=<%= cacheVersion %>"></script>
<script src="/js/rdclient/compress.js?v=<%= cacheVersion %>"></script>
<script src="/js/rdclient/clipboard.js?v=<%= cacheVersion %>"></script>
<script src="/js/rdclient/filetransfer.js?v=<%= cacheVersion %>"></script>
<script src="/js/rdclient/client.js?v=<%= cacheVersion %>"></script>
<!-- CDAP transport adapter (RDClient-compatible surface for OS-agent devices) -->