diff --git a/web-nodejs/public/js/rdclient/client.js b/web-nodejs/public/js/rdclient/client.js index 605e3d6d..5dd392f1 100644 --- a/web-nodejs/public/js/rdclient/client.js +++ b/web-nodejs/public/js/rdclient/client.js @@ -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); } diff --git a/web-nodejs/public/js/rdclient/clipboard.js b/web-nodejs/public/js/rdclient/clipboard.js new file mode 100644 index 00000000..6b2dc244 --- /dev/null +++ b/web-nodejs/public/js/rdclient/clipboard.js @@ -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(//gi, '') + .replace(//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} + */ + 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} + */ + 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} + */ + 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} + */ + 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; + } +} diff --git a/web-nodejs/public/js/rdclient/compress.js b/web-nodejs/public/js/rdclient/compress.js new file mode 100644 index 00000000..595b8802 --- /dev/null +++ b/web-nodejs/public/js/rdclient/compress.js @@ -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} + */ + 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 }; + } +} diff --git a/web-nodejs/public/js/rdclient/filetransfer.js b/web-nodejs/public/js/rdclient/filetransfer.js index 27959fd4..450e8a96 100644 --- a/web-nodejs/public/js/rdclient/filetransfer.js +++ b/web-nodejs/public/js/rdclient/filetransfer.js @@ -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} */ 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 }); } /** diff --git a/web-nodejs/public/js/rdclient/protocol.js b/web-nodejs/public/js/rdclient/protocol.js index b1fae9c4..d9c7bd67 100644 --- a/web-nodejs/public/js/rdclient/protocol.js +++ b/web-nodejs/public/js/rdclient/protocol.js @@ -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} */ - 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 } }; diff --git a/web-nodejs/tests/rdclient.clipboard.test.js b/web-nodejs/tests/rdclient.clipboard.test.js new file mode 100644 index 00000000..cd1b60d1 --- /dev/null +++ b/web-nodejs/tests/rdclient.clipboard.test.js @@ -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('

Hello world

')).toBe('Hello world'); + }); + + it('pickBestText prefers Text over Html and Rtf', () => { + const entries = [ + { format: 'html', text: 'html plain', html: 'x' }, + { 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: 'x' }, + { 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'); + }); +}); diff --git a/web-nodejs/views/layouts/viewer.ejs b/web-nodejs/views/layouts/viewer.ejs index 4665909b..74d60040 100644 --- a/web-nodejs/views/layouts/viewer.ejs +++ b/web-nodejs/views/layouts/viewer.ejs @@ -60,6 +60,8 @@ + +