Files
BetterDesk/web-nodejs/public/js/rdclient/protocol.js
T
UNITRONIX 35995fed08 fix(remote): send 2FA code with correct protobuf field (#171)
Web remote was encoding Auth2FA under auth2Fa, which protobuf.js drops as an
empty message, so RustDesk peers never received the TOTP code after Verify.
2026-06-06 14:55:03 +02:00

812 lines
28 KiB
JavaScript

/**
* BetterDesk Web Remote Client - Protocol Handler
* Implements RustDesk rendezvous and peer-to-peer protocol using protobuf
*/
/* global protobuf, RDConnection, RDCrypto */
// eslint-disable-next-line no-unused-vars
class RDProtocol {
constructor() {
/** @type {Object} Loaded protobuf root */
this.protoRoot = null;
/** @type {Object} Message types cache */
this.types = {};
/** @type {boolean} */
this.loaded = false;
}
/**
* Load and parse .proto files
*/
async load() {
if (this.loaded) return;
this.protoRoot = await protobuf.load([
'/protos/rendezvous.proto',
'/protos/message.proto'
]);
// Cache commonly used message types
this.types.RendezvousMessage = this.protoRoot.lookupType('hbb.RendezvousMessage');
this.types.Message = this.protoRoot.lookupType('hbb.Message');
this.types.PunchHoleRequest = this.protoRoot.lookupType('hbb.PunchHoleRequest');
this.types.PunchHoleResponse = this.protoRoot.lookupType('hbb.PunchHoleResponse');
this.types.RequestRelay = this.protoRoot.lookupType('hbb.RequestRelay');
this.types.RelayResponse = this.protoRoot.lookupType('hbb.RelayResponse');
this.types.LoginRequest = this.protoRoot.lookupType('hbb.LoginRequest');
this.types.LoginResponse = this.protoRoot.lookupType('hbb.LoginResponse');
this.types.SignedId = this.protoRoot.lookupType('hbb.SignedId');
this.types.PublicKey = this.protoRoot.lookupType('hbb.PublicKey');
this.types.Hash = this.protoRoot.lookupType('hbb.Hash');
this.types.MouseEvent = this.protoRoot.lookupType('hbb.MouseEvent');
this.types.KeyEvent = this.protoRoot.lookupType('hbb.KeyEvent');
this.types.VideoFrame = this.protoRoot.lookupType('hbb.VideoFrame');
this.types.AudioFrame = this.protoRoot.lookupType('hbb.AudioFrame');
this.types.CursorData = this.protoRoot.lookupType('hbb.CursorData');
this.types.CursorPosition = this.protoRoot.lookupType('hbb.CursorPosition');
this.types.TestDelay = this.protoRoot.lookupType('hbb.TestDelay');
this.types.Misc = this.protoRoot.lookupType('hbb.Misc');
this.types.OptionMessage = this.protoRoot.lookupType('hbb.OptionMessage');
this.types.SupportedDecoding = this.protoRoot.lookupType('hbb.SupportedDecoding');
this.types.Clipboard = this.protoRoot.lookupType('hbb.Clipboard');
this.types.PeerInfo = this.protoRoot.lookupType('hbb.PeerInfo');
this.types.IdPk = this.protoRoot.lookupType('hbb.IdPk');
this.types.Resolution = this.protoRoot.lookupType('hbb.Resolution');
this.types.ChatMessage = this.protoRoot.lookupType('hbb.ChatMessage');
this.types.TogglePrivacyMode = this.protoRoot.lookupType('hbb.TogglePrivacyMode');
this.types.SwitchDisplay = this.protoRoot.lookupType('hbb.SwitchDisplay');
this.types.Auth2FA = this.protoRoot.lookupType('hbb.Auth2FA');
// File transfer types
this.types.FileAction = this.protoRoot.lookupType('hbb.FileAction');
this.types.FileResponse = this.protoRoot.lookupType('hbb.FileResponse');
this.types.FileDirectory = this.protoRoot.lookupType('hbb.FileDirectory');
this.types.FileEntry = this.protoRoot.lookupType('hbb.FileEntry');
this.types.ReadDir = this.protoRoot.lookupType('hbb.ReadDir');
this.types.FileTransferSendRequest = this.protoRoot.lookupType('hbb.FileTransferSendRequest');
this.types.FileTransferReceiveRequest = this.protoRoot.lookupType('hbb.FileTransferReceiveRequest');
this.types.FileTransferBlock = this.protoRoot.lookupType('hbb.FileTransferBlock');
this.types.FileTransferDigest = this.protoRoot.lookupType('hbb.FileTransferDigest');
this.types.FileTransferDone = this.protoRoot.lookupType('hbb.FileTransferDone');
this.types.FileTransferCancel = this.protoRoot.lookupType('hbb.FileTransferCancel');
this.types.FileTransferSendConfirmRequest = this.protoRoot.lookupType('hbb.FileTransferSendConfirmRequest');
this.types.FileTransferError = this.protoRoot.lookupType('hbb.FileTransferError');
this.types.FileDirCreate = this.protoRoot.lookupType('hbb.FileDirCreate');
this.types.FileRemoveDir = this.protoRoot.lookupType('hbb.FileRemoveDir');
this.types.FileRemoveFile = this.protoRoot.lookupType('hbb.FileRemoveFile');
this.types.FileRename = this.protoRoot.lookupType('hbb.FileRename');
this.types.FileTransfer = this.protoRoot.lookupType('hbb.FileTransfer');
// Enums
this.enums = {};
this.enums.ConnType = this.protoRoot.lookupEnum('hbb.ConnType');
this.enums.NatType = this.protoRoot.lookupEnum('hbb.NatType');
this.enums.ControlKey = this.protoRoot.lookupEnum('hbb.ControlKey');
this.enums.KeyboardMode = this.protoRoot.lookupEnum('hbb.KeyboardMode');
this.enums.ImageQuality = this.protoRoot.lookupEnum('hbb.ImageQuality');
this.enums.ClipboardFormat = this.protoRoot.lookupEnum('hbb.ClipboardFormat');
this.enums.FileType = this.protoRoot.lookupEnum('hbb.FileType');
this.enums.PreferCodec = this.protoRoot.lookupEnum('hbb.SupportedDecoding.PreferCodec');
this.loaded = true;
}
// ---- Encoding helpers ----
/**
* Encode a RendezvousMessage with RustDesk variable-length frame header
* (used for direct rendezvous communication, no encryption)
*/
encodeRendezvous(msgObj) {
const msg = this.types.RendezvousMessage.create(msgObj);
const buf = this.types.RendezvousMessage.encode(msg).finish();
return this._encodeFrame(buf);
}
/**
* Serialize a Message to raw protobuf bytes (NO frame header)
* Use this when encryption will be applied before framing
*
* Uses fromObject() (not create()) so that string enum names — e.g. a
* KeyEvent's `controlKey: 'Backspace'` or `mode: 'Legacy'` — are converted
* to their numeric enum values. create() leaves them as strings, which the
* encoder writes as 0 (Unknown), breaking every non-character key. Byte
* fields supplied as Uint8Array (clipboard content, file blocks, keys) are
* preserved as-is by fromObject().
*/
serializeMessage(msgObj) {
const msg = this.types.Message.fromObject(msgObj);
return this.types.Message.encode(msg).finish();
}
/**
* Encode a Message (peer-to-peer) with RustDesk variable-length frame header
* (used when no encryption is active)
*/
encodeMessage(msgObj) {
return this._encodeFrame(this.serializeMessage(msgObj));
}
/**
* Add frame header to raw bytes (e.g. after encryption)
* @param {Uint8Array} rawBytes
* @returns {Uint8Array}
*/
frameBytes(rawBytes) {
return this._encodeFrame(rawBytes);
}
/**
* Decode a RendezvousMessage from raw protobuf bytes (frame header already stripped)
* @param {Uint8Array} protoBytes - Raw protobuf payload
*/
decodeRendezvous(protoBytes) {
return this.types.RendezvousMessage.decode(protoBytes);
}
/**
* Decode a Message (peer-to-peer) from raw protobuf bytes (frame header already stripped)
* @param {Uint8Array} protoBytes - Raw protobuf payload
*/
decodeMessage(protoBytes) {
return this.types.Message.decode(protoBytes);
}
// ---- Protocol message builders ----
/**
* Build PunchHoleRequest for connecting to a device
* @param {string} deviceId
* @param {string} [serverKey] - Server public key (base64) for licence_key validation
*/
buildPunchHoleRequest(deviceId, serverKey) {
return {
punchHoleRequest: {
id: deviceId,
natType: this.enums.NatType.values.SYMMETRIC, // Browser always NAT
licenceKey: serverKey || '',
connType: this.enums.ConnType.values.DEFAULT_CONN,
token: '',
version: 'BetterDesk-Web/1.0',
forceRelay: true // Browser must use relay
}
};
}
/**
* Build RequestRelay message
* @param {string} deviceId - Target device ID
* @param {string} uuid - Relay session UUID (from RelayResponse)
* @param {string} relayServer - Relay server address
* @param {string} [serverKey] - Server public key for licence_key validation (hbbr checks this)
*/
buildRequestRelay(deviceId, uuid, relayServer, serverKey) {
return {
requestRelay: {
id: deviceId,
uuid: uuid,
relayServer: relayServer || '',
licenceKey: serverKey || '',
secure: false,
connType: this.enums.ConnType.values.DEFAULT_CONN,
token: ''
}
};
}
/**
* Build PublicKey message for secure handshake
*/
buildPublicKey(asymPk, symKey) {
return {
publicKey: {
asymmetricValue: asymPk,
symmetricValue: symKey
}
};
}
/**
* Build Auth2FA message for peer TOTP verification (RustDesk unattended 2FA).
* @param {string} code - 6-digit TOTP code
* @param {Uint8Array} [hwid] - Optional hardware id for trusted-device bypass
*/
buildAuth2FA(code, hwid) {
const auth2fa = { code: String(code || '').trim() };
if (hwid && hwid.length) auth2fa.hwid = hwid;
return { auth_2fa: auth2fa };
}
/**
* Build LoginRequest message
* @param {Uint8Array} passwordHash
* @param {Object} opts
*/
buildLoginRequest(passwordHash, opts = {}) {
// Dynamically detect codec support
const hasWebCodecs = typeof VideoDecoder !== 'undefined';
const hasJMuxer = typeof JMuxer !== 'undefined';
// Default to Best quality for sharpest image
const quality = opts.imageQuality || 'Best';
return {
loginRequest: {
username: opts.username || '',
password: passwordHash,
myId: opts.myId || 'web-client',
myName: opts.myName || 'BetterDesk Web',
myPlatform: 'Web',
version: 'BetterDesk-Web/1.0',
sessionId: Date.now(),
option: {
imageQuality: this.enums.ImageQuality.values[quality] || this.enums.ImageQuality.values.Best,
supportedDecoding: this.buildSupportedDecoding({
abilities: opts.codecAbilities,
prefer: opts.preferCodec,
hasWebCodecs,
hasJMuxer
}),
customFps: opts.fps || 60,
showRemoteCursor: 2, // Yes
disableAudio: opts.disableAudio ? 2 : 1,
disableClipboard: 1, // No
enableFileTransfer: 2, // Yes — required for file transfer on DEFAULT_CONN
lockAfterSessionEnd: 1 // No
}
}
};
}
/**
* Map a PreferCodec name (Auto/VP9/H264/H265/VP8/AV1) to its enum value.
* @param {string} name
* @returns {number}
*/
preferCodecValue(name) {
if (!name) return 0;
const v = this.enums.PreferCodec.values;
return (v && v[name] !== undefined) ? v[name] : 0;
}
/**
* Build a SupportedDecoding object advertising decode abilities + preferred codec.
* @param {Object} o
* @param {Object} [o.abilities] - { vp9, h264, h265, av1, vp8 } booleans (probed)
* @param {string} [o.prefer] - PreferCodec name (Auto/VP9/H264/H265/VP8/AV1)
* @param {boolean} [o.hasWebCodecs]
* @param {boolean} [o.hasJMuxer]
* @returns {Object}
*/
buildSupportedDecoding(o = {}) {
const hasWebCodecs = o.hasWebCodecs !== undefined ? o.hasWebCodecs : (typeof VideoDecoder !== 'undefined');
const hasJMuxer = o.hasJMuxer !== undefined ? o.hasJMuxer : (typeof JMuxer !== 'undefined');
const a = o.abilities || null;
const can = (key, fallback) => a ? (a[key] ? 1 : 0) : (fallback ? 1 : 0);
return {
abilityVp9: can('vp9', hasWebCodecs),
abilityH264: can('h264', hasWebCodecs || hasJMuxer),
abilityH265: can('h265', false),
abilityAv1: can('av1', hasWebCodecs),
abilityVp8: can('vp8', hasWebCodecs),
prefer: this.preferCodecValue(o.prefer || (hasWebCodecs ? 'Auto' : 'H264'))
};
}
/**
* Build MouseEvent message
*/
buildMouseEvent(mask, x, y, modifiers = []) {
return {
mouseEvent: { mask, x, y, modifiers }
};
}
/**
* Build KeyEvent message
*/
buildKeyEvent(keyData) {
return {
keyEvent: keyData
};
}
/**
* Build TestDelay message (ping)
*/
buildTestDelay() {
return {
testDelay: {
time: Date.now(),
fromClient: true,
lastDelay: 0,
targetBitrate: 0
}
};
}
/**
* Build Clipboard message
*/
buildClipboard(text) {
const encoder = new TextEncoder();
return {
clipboard: {
compress: false,
content: encoder.encode(text),
format: this.enums.ClipboardFormat.values.Text
}
};
}
/**
* Build a Misc message wrapping an OptionMessage for mid-session setting changes.
* BoolOption values: NotSet=0, No=1, Yes=2
* @param {Object} opts - OptionMessage fields
*/
buildOptionMisc(opts = {}) {
const optionMsg = {};
if (opts.imageQuality !== undefined) {
optionMsg.imageQuality = this.enums.ImageQuality.values[opts.imageQuality] || 0;
}
if (opts.customImageQuality !== undefined) {
optionMsg.customImageQuality = opts.customImageQuality;
}
if (opts.customFps !== undefined) {
optionMsg.customFps = opts.customFps;
}
if (opts.lockAfterSessionEnd !== undefined) {
optionMsg.lockAfterSessionEnd = opts.lockAfterSessionEnd ? 2 : 1;
}
if (opts.showRemoteCursor !== undefined) {
optionMsg.showRemoteCursor = opts.showRemoteCursor ? 2 : 1;
}
if (opts.privacyMode !== undefined) {
optionMsg.privacyMode = opts.privacyMode ? 2 : 1;
}
if (opts.blockInput !== undefined) {
optionMsg.blockInput = opts.blockInput ? 2 : 1;
}
if (opts.disableAudio !== undefined) {
optionMsg.disableAudio = opts.disableAudio ? 2 : 1;
}
if (opts.disableClipboard !== undefined) {
optionMsg.disableClipboard = opts.disableClipboard ? 2 : 1;
}
if (opts.disableKeyboard !== undefined) {
optionMsg.disableKeyboard = opts.disableKeyboard ? 2 : 1;
}
if (opts.supportedDecoding !== undefined) {
optionMsg.supportedDecoding = this.buildSupportedDecoding(opts.supportedDecoding);
}
return {
misc: { option: optionMsg }
};
}
/**
* Build a Misc message for various control commands
* @param {string} field - Misc oneof field name
* @param {*} value - Field value
*/
buildMisc(field, value) {
const misc = {};
misc[field] = value;
return { misc: misc };
}
/**
* Build ChatMessage wrapped in Misc
* @param {string} text
*/
buildChatMessage(text) {
return {
misc: {
chatMessage: { text: text }
}
};
}
/**
* Build TogglePrivacyMode wrapped in Misc
* @param {boolean} on
*/
buildTogglePrivacyMode(on) {
return {
misc: {
togglePrivacyMode: {
implKey: 'privacy_mode_impl_virtual_display',
on: on
}
}
};
}
// ---- File Transfer builders ----
/**
* Build FileAction.read_dir message
* @param {string} path - Directory path to read
* @param {boolean} [includeHidden=false]
* @returns {Object} Message object for _sendPeerMessage
*/
buildReadDir(path, includeHidden) {
return {
fileAction: {
readDir: {
path: path || '',
includeHidden: !!includeHidden
}
}
};
}
/**
* Build FileAction.receive (request download from remote)
* @param {number} id - Transfer ID
* @param {string} path - Remote directory path
* @param {Array<Object>} files - Array of { name, size, modified_time, entry_type }
* @param {number} fileNum - File sequence number in transfer
* @param {number} totalSize - Total bytes
* @returns {Object}
*/
buildFileReceiveRequest(id, path, files, fileNum, totalSize) {
return {
fileAction: {
receive: {
id: id,
path: path,
files: files || [],
fileNum: fileNum || 0,
totalSize: totalSize || 0
}
}
};
}
/**
* Build FileAction.send (request upload to remote)
* @param {number} id - Transfer ID
* @param {string} path - Remote destination path
* @param {boolean} [includeHidden]
* @param {number} [fileNum]
* @returns {Object}
*/
buildFileSendRequest(id, path, includeHidden, fileNum) {
return {
fileAction: {
send: {
id: id,
path: path,
includeHidden: !!includeHidden,
fileNum: fileNum || 0
}
}
};
}
/**
* Build FileAction.send_confirm (confirm/skip download after digest)
* @param {number} id - Transfer ID
* @param {number} fileNum - File number
* @param {boolean} skip - Whether to skip this file
* @param {number} [offsetBlk] - Block offset for resume
* @returns {Object}
*/
buildFileSendConfirm(id, fileNum, skip, offsetBlk) {
const confirm = { id: id, fileNum: fileNum };
if (skip) {
confirm.skip = true;
} else {
confirm.offsetBlk = offsetBlk || 0;
}
return {
fileAction: { sendConfirm: confirm }
};
}
/**
* Build FileAction.cancel
* @param {number} id - Transfer ID to cancel
* @returns {Object}
*/
buildFileCancel(id) {
return {
fileAction: {
cancel: { id: id }
}
};
}
/**
* Build FileAction.create (create directory)
* @param {number} id
* @param {string} path
* @returns {Object}
*/
buildFileDirCreate(id, path) {
return {
fileAction: {
create: { id: id, path: path }
}
};
}
/**
* Build FileAction.remove_file
* @param {number} id
* @param {string} path
* @param {number} fileNum
* @returns {Object}
*/
buildFileRemove(id, path, fileNum) {
return {
fileAction: {
removeFile: { id: id, path: path, fileNum: fileNum || 0 }
}
};
}
/**
* Build FileAction.remove_dir
* @param {number} id
* @param {string} path
* @param {boolean} recursive
* @returns {Object}
*/
buildFileRemoveDir(id, path, recursive) {
return {
fileAction: {
removeDir: { id: id, path: path, recursive: !!recursive }
}
};
}
/**
* Build FileAction.rename
* @param {number} id
* @param {string} path
* @param {string} newName
* @returns {Object}
*/
buildFileRename(id, path, newName) {
return {
fileAction: {
rename: { id: id, path: path, newName: newName }
}
};
}
/**
* Build FileResponse.block (upload data block)
* @param {number} id
* @param {number} fileNum
* @param {Uint8Array} data
* @param {boolean} compressed
* @param {number} blkId
* @returns {Object}
*/
buildFileBlock(id, fileNum, data, compressed, blkId) {
return {
fileResponse: {
block: {
id: id,
fileNum: fileNum,
data: data,
compressed: !!compressed,
blkId: blkId || 0
}
}
};
}
/**
* Build FileResponse.done (upload complete)
* @param {number} id
* @param {number} fileNum
* @returns {Object}
*/
buildFileDone(id, fileNum) {
return {
fileResponse: {
done: { id: id, fileNum: fileNum }
}
};
}
// ---- Message parsing helpers ----
/**
* Get the active field name from a oneof message
*/
getOneOfField(msg, oneofName) {
if (!msg) return null;
// protobufjs stores the active field name directly
for (const key of Object.keys(msg)) {
if (key !== oneofName && msg[key] != null) {
return key;
}
}
return null;
}
/**
* Detect video codec from VideoFrame message
*/
detectVideoCodec(videoFrame) {
if (videoFrame.vp9s) return 'vp9';
if (videoFrame.h264s) return 'h264';
if (videoFrame.h265s) return 'h265';
if (videoFrame.vp8s) return 'vp8';
if (videoFrame.av1s) return 'av1';
if (videoFrame.rgb) return 'rgb';
if (videoFrame.yuv) return 'yuv';
return null;
}
/**
* Extract encoded frames from VideoFrame
*/
getEncodedFrames(videoFrame) {
const codec = this.detectVideoCodec(videoFrame);
if (!codec || codec === 'rgb' || codec === 'yuv') return [];
const key = codec + 's';
const container = videoFrame[key];
if (!container || !container.frames) return [];
return container.frames.map(f => ({
data: f.data,
key: f.key,
pts: f.pts,
codec: codec
}));
}
// ---- RustDesk Variable-Length Frame Codec ----
// hbb_common/src/bytes_codec.rs uses a variable-length header:
// bottom 2 bits of byte 0 = (header_length - 1)
// remaining bits (shifted right 2) = payload length
// Header is 1-4 bytes, little-endian.
// 1 byte: payload ≤ 63 bytes
// 2 bytes: payload ≤ 16383 bytes
// 3 bytes: payload ≤ 4194303 bytes
// 4 bytes: payload ≤ 1073741823 bytes
/**
* Encode protobuf bytes into a RustDesk frame (variable-length header + payload)
* @param {Uint8Array} buf - Raw protobuf bytes
* @returns {Uint8Array}
*/
_encodeFrame(buf) {
const len = buf.length;
let header;
if (len <= 0x3F) {
header = new Uint8Array(1);
header[0] = (len << 2); // bottom 2 bits = 0b00
} else if (len <= 0x3FFF) {
header = new Uint8Array(2);
const val = (len << 2) | 0x1; // bottom 2 bits = 0b01
header[0] = val & 0xFF;
header[1] = (val >> 8) & 0xFF;
} else if (len <= 0x3FFFFF) {
header = new Uint8Array(3);
const val = (len << 2) | 0x2; // bottom 2 bits = 0b10
header[0] = val & 0xFF;
header[1] = (val >> 8) & 0xFF;
header[2] = (val >> 16) & 0xFF;
} else if (len <= 0x3FFFFFFF) {
header = new Uint8Array(4);
const val = (len << 2) | 0x3; // bottom 2 bits = 0b11
header[0] = val & 0xFF;
header[1] = (val >> 8) & 0xFF;
header[2] = (val >> 16) & 0xFF;
header[3] = (val >>> 24) & 0xFF;
} else {
throw new Error('Frame too large');
}
const result = new Uint8Array(header.length + buf.length);
result.set(header);
result.set(buf, header.length);
return result;
}
/**
* Try to decode one frame from a byte buffer at the given offset
* @param {Uint8Array} bytes
* @param {number} offset
* @returns {{ data: Uint8Array, consumed: number } | null} null if incomplete
*/
_decodeFrame(bytes, offset) {
if (offset >= bytes.length) return null;
const firstByte = bytes[offset];
const headLen = (firstByte & 0x3) + 1;
if (offset + headLen > bytes.length) return null;
let n = bytes[offset];
if (headLen > 1) n |= bytes[offset + 1] << 8;
if (headLen > 2) n |= bytes[offset + 2] << 16;
if (headLen > 3) n |= bytes[offset + 3] << 24;
n >>>= 2; // unsigned right shift to get payload length
if (offset + headLen + n > bytes.length) return null;
return {
data: bytes.slice(offset + headLen, offset + headLen + n),
consumed: headLen + n
};
}
/**
* Create a stateful stream decoder that handles TCP reassembly
* Feed it raw WebSocket binary data chunks, and it returns complete protobuf frames.
* Uses a pre-allocated growing buffer to avoid O(n²) copies on every chunk.
* @returns {{ feed: (data: ArrayBuffer|Uint8Array) => Uint8Array[] }}
*/
createStreamDecoder() {
let buffer = new Uint8Array(0);
/** @type {number} Valid data length within buffer (buffer.length may be larger) */
let dataLen = 0;
const self = this;
return {
/**
* Feed raw data from WebSocket and extract complete frames
* @param {ArrayBuffer|Uint8Array} data
* @returns {Uint8Array[]} Array of raw protobuf payloads (without frame headers)
*/
feed(data) {
const incoming = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
// Append to buffer (grow capacity if needed)
const needed = dataLen + incoming.length;
if (needed > buffer.length) {
// Grow to at least 2x current or needed, whichever is larger
const newCap = Math.max(needed, buffer.length * 2, 4096);
const newBuf = new Uint8Array(newCap);
if (dataLen > 0) newBuf.set(buffer.subarray(0, dataLen));
buffer = newBuf;
}
buffer.set(incoming, dataLen);
dataLen += incoming.length;
const frames = [];
let offset = 0;
// Decode frames using a view of the valid portion
const view = buffer.subarray(0, dataLen);
while (offset < dataLen) {
const frame = self._decodeFrame(view, offset);
if (!frame) break; // Incomplete frame, wait for more data
frames.push(frame.data);
offset += frame.consumed;
}
// Compact: move unconsumed bytes to front of buffer
if (offset > 0) {
const remaining = dataLen - offset;
if (remaining > 0) {
buffer.copyWithin(0, offset, dataLen);
}
dataLen = remaining;
}
return frames;
},
/** Reset internal buffer */
reset() {
buffer = new Uint8Array(0);
dataLen = 0;
}
};
}
}
window.RDProtocol = RDProtocol;