mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 09:35:39 +00:00
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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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) -->
|
||||
|
||||
Reference in New Issue
Block a user