Complete browser-based remote desktop client built from protocol analysis: - Protobuf message definitions (75+ message types, 10 enums) - NaCl encryption (Ed25519 + Curve25519 ECDH + XSalsa20-Poly1305) - WebSocket signaling (hbbs) + relay (hbbr) connection lifecycle - VP8/VP9/AV1 video decoding via ogvjs WASM + yuv-canvas WebGL - Opus audio decoding via libopus WASM + Web Audio API - Full mouse/keyboard input forwarding to protobuf events - Connection dialog, status bar, toolbar, log panel UI Also tracked web_deps (codec libraries) in rustdesk-as-ref/
1018 lines
34 KiB
JavaScript
1018 lines
34 KiB
JavaScript
/**
|
|
* RustDesk Standalone Web Client - Connection Manager
|
|
*
|
|
* Manages WebSocket connections to hbbs (signaling) and hbbr (relay),
|
|
* handles the full connection lifecycle including registration,
|
|
* punch hole, relay setup, and key exchange.
|
|
*/
|
|
|
|
const RDConnection = (() => {
|
|
// Connection states
|
|
const STATE = {
|
|
DISCONNECTED: 'disconnected',
|
|
CONNECTING: 'connecting',
|
|
REGISTERING: 'registering',
|
|
PUNCHING: 'punching',
|
|
RELAYING: 'relaying',
|
|
KEY_EXCHANGE: 'key_exchange',
|
|
AUTHENTICATING: 'authenticating',
|
|
CONNECTED: 'connected',
|
|
FAILED: 'failed',
|
|
CLOSED: 'closed'
|
|
};
|
|
|
|
let _state = STATE.DISCONNECTED;
|
|
let _hbbsWs = null; // WebSocket to hbbs (signaling)
|
|
let _sessionWs = null; // WebSocket to hbbr/peer (session)
|
|
let _serverAddr = ''; // Server address (host or host:port)
|
|
let _useTls = false; // Whether to use wss://
|
|
let _serverKey = ''; // Server's Ed25519 public key (for encrypted hbbs)
|
|
let _myId = ''; // Our registered client ID
|
|
let _myName = ''; // Our display name
|
|
let _myUuid = ''; // Our UUID
|
|
let _targetPeerId = ''; // Remote peer ID we want to connect to
|
|
let _peerPk = null; // Peer's Ed25519 public key from PunchHoleResponse
|
|
let _relayUuid = ''; // UUID for relay connection
|
|
let _hbbsEncrypted = false; // Whether hbbs connection is encrypted
|
|
let _hbbsSymmetricKey = null; // Symmetric key for hbbs encryption
|
|
let _hbbsSendSeq = 0;
|
|
let _hbbsRecvSeq = 0;
|
|
let _peerPlatform = '';
|
|
let _peerVersion = '';
|
|
let _passwordCallback = null; // Callback for password input
|
|
let _2faCallback = null; // Callback for 2FA input
|
|
let _onPeerInfo = null; // Callback for peer info received
|
|
let _onVideoFrame = null; // Callback for video frame received
|
|
let _onAudioFrame = null; // Callback for audio frame received
|
|
let _onCursorData = null; // Callback for cursor data received
|
|
let _onCursorPosition = null; // Callback for cursor position received
|
|
let _onMiscEvent = null; // Callback for misc events received
|
|
let _onClipboard = null; // Callback for clipboard events
|
|
let _onClose = null; // Callback for session close
|
|
let _onStateChange = null; // Callback for state changes
|
|
let _onError = null; // Callback for errors
|
|
let _msgBuffer = []; // Buffer for incomplete messages
|
|
let _serial = 0; // Registration serial number
|
|
let _keepAliveInterval = null;
|
|
let _latencyInterval = null;
|
|
|
|
/**
|
|
* Generate a random 9-digit client ID for web client
|
|
*/
|
|
function generateClientId() {
|
|
const chars = '23456789abcdefghijkmnpqrstuvwxyz';
|
|
let id = '';
|
|
for (let i = 0; i < 9; i++) {
|
|
id += chars[Math.floor(Math.random() * chars.length)];
|
|
}
|
|
return id;
|
|
}
|
|
|
|
/**
|
|
* Generate a UUID v4
|
|
*/
|
|
function generateUUID() {
|
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
|
const r = Math.random() * 16 | 0;
|
|
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Build WebSocket URL
|
|
*/
|
|
function buildWsUrl(host, path) {
|
|
const protocol = _useTls ? 'wss' : 'ws';
|
|
// Detect if host is IP address
|
|
const isIp = /^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes('://');
|
|
|
|
if (isIp) {
|
|
// For IP: add +3 to port for WebSocket, or use default ports
|
|
// ws://ip:port format (no path)
|
|
let [hostname, port] = host.split(':');
|
|
if (!port) port = '21115'; // Default hbbs TCP port
|
|
port = parseInt(port) + 3; // WebSocket port offset
|
|
return `${protocol}://${hostname}:${port}`;
|
|
} else {
|
|
// For domain: use path-based routing
|
|
const base = host.includes('://') ? host : `${protocol}://${host}`;
|
|
return `${base}${path}`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize and connect to the server
|
|
*/
|
|
async function connect(serverAddr, options = {}) {
|
|
_serverAddr = serverAddr;
|
|
_useTls = options.tls !== false;
|
|
_serverKey = options.serverKey || '';
|
|
_myId = options.clientId || generateClientId();
|
|
_myName = options.clientName || `Web-${_myId.substring(0, 4)}`;
|
|
_myUuid = options.uuid || generateUUID();
|
|
_passwordCallback = options.onPassword || null;
|
|
_2faCallback = options.on2FA || null;
|
|
_onPeerInfo = options.onPeerInfo || null;
|
|
_onVideoFrame = options.onVideoFrame || null;
|
|
_onAudioFrame = options.onAudioFrame || null;
|
|
_onCursorData = options.onCursorData || null;
|
|
_onCursorPosition = options.onCursorPosition || null;
|
|
_onMiscEvent = options.onMiscEvent || null;
|
|
_onClipboard = options.onClipboard || null;
|
|
_onClose = options.onClose || null;
|
|
_onStateChange = options.onStateChange || null;
|
|
_onError = options.onError || null;
|
|
|
|
// Generate keypairs
|
|
RDCrypto.generateSignKeyPair();
|
|
RDCrypto.generateBoxKeyPair();
|
|
|
|
setState(STATE.CONNECTING);
|
|
console.log('[RDConn] Connecting to', serverAddr);
|
|
|
|
try {
|
|
await connectHbbs();
|
|
} catch (e) {
|
|
console.error('[RDConn] Connection failed:', e);
|
|
setState(STATE.FAILED);
|
|
if (_onError) _onError(e.message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Connect to hbbs signaling server via WebSocket
|
|
*/
|
|
function connectHbbs() {
|
|
return new Promise((resolve, reject) => {
|
|
const url = buildWsUrl(_serverAddr, '/ws/id');
|
|
console.log('[RDConn] Connecting to hbbs at', url);
|
|
|
|
_hbbsWs = new WebSocket(url);
|
|
_hbbsWs.binaryType = 'arraybuffer';
|
|
|
|
_hbbsWs.onopen = () => {
|
|
console.log('[RDConn] Connected to hbbs');
|
|
// First message from server should be KeyExchange (if encrypted)
|
|
// Wait for it before registering
|
|
};
|
|
|
|
_hbbsWs.onmessage = (event) => {
|
|
handleHbbsMessage(new Uint8Array(event.data)).then(resolve).catch(reject);
|
|
};
|
|
|
|
_hbbsWs.onerror = (e) => {
|
|
console.error('[RDConn] hbbs WebSocket error:', e);
|
|
reject(new Error('Failed to connect to signaling server'));
|
|
};
|
|
|
|
_hbbsWs.onclose = (e) => {
|
|
console.log('[RDConn] hbbs WebSocket closed:', e.code, e.reason);
|
|
if (_state !== STATE.CONNECTED && _state !== STATE.CLOSED) {
|
|
setState(STATE.FAILED);
|
|
if (_onError) _onError('Signaling connection closed');
|
|
}
|
|
};
|
|
|
|
// Timeout for connection
|
|
setTimeout(() => {
|
|
if (_hbbsWs && _hbbsWs.readyState !== WebSocket.OPEN) {
|
|
reject(new Error('Connection timeout'));
|
|
}
|
|
}, 10000);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle incoming messages from hbbs
|
|
*/
|
|
async function handleHbbsMessage(data) {
|
|
let msgData = data;
|
|
|
|
// Decrypt if hbbs connection is encrypted
|
|
if (RDCrypto.isHbbsEncrypted()) {
|
|
const decrypted = RDCrypto.hbbsDecrypt(data);
|
|
if (!decrypted) {
|
|
console.error('[RDConn] Failed to decrypt hbbs message');
|
|
return;
|
|
}
|
|
msgData = decrypted;
|
|
}
|
|
|
|
const rvMsg = RDProto.decodeRendezvousMsg(msgData);
|
|
const field = getRvField(rvMsg);
|
|
|
|
switch (field) {
|
|
case 'key_exchange':
|
|
await handleServerKeyExchange(rvMsg.key_exchange);
|
|
break;
|
|
|
|
case 'register_peer_response':
|
|
if (rvMsg.register_peer_response.request_pk) {
|
|
sendRegisterPk();
|
|
}
|
|
break;
|
|
|
|
case 'register_pk_response':
|
|
console.log('[RDConn] Registration result:', rvMsg.register_pk_response.result);
|
|
setState(STATE.PUNCHING);
|
|
sendPunchHole();
|
|
break;
|
|
|
|
case 'punch_hole_response':
|
|
await handlePunchHoleResponse(rvMsg.punch_hole_response);
|
|
break;
|
|
|
|
case 'relay_response':
|
|
await handleRelayResponse(rvMsg.relay_response);
|
|
break;
|
|
|
|
case 'online_response':
|
|
handleOnlineResponse(rvMsg.online_response);
|
|
break;
|
|
|
|
case 'configure_update':
|
|
console.log('[RDConn] Server config update received');
|
|
break;
|
|
|
|
default:
|
|
console.log('[RDConn] Unhandled hbbs message:', field, rvMsg);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle server key exchange (encrypted hbbs connection)
|
|
*/
|
|
async function handleServerKeyExchange(keyExchange) {
|
|
if (keyExchange.keys.length < 1) {
|
|
console.log('[RDConn] No server key provided, connection is unencrypted');
|
|
sendRegisterPeer();
|
|
return;
|
|
}
|
|
|
|
if (!_serverKey) {
|
|
console.log('[RDConn] No server key configured, skipping verification');
|
|
sendRegisterPeer();
|
|
return;
|
|
}
|
|
|
|
// Parse server's long-term Ed25519 public key from hex
|
|
const serverPk = hexToBytes(_serverKey);
|
|
|
|
// Verify the server's ephemeral key signature
|
|
const serverEphemeralPk = RDCrypto.verifyServerKey(keyExchange.keys[0], serverPk);
|
|
if (!serverEphemeralPk) {
|
|
console.error('[RDConn] Server key verification failed');
|
|
sendRegisterPeer();
|
|
return;
|
|
}
|
|
|
|
// Setup hbbs encryption using a separate keypair (not the session keypair)
|
|
RDCrypto.generateHbbsKeyPair();
|
|
RDCrypto.setupHbbsEncryption(serverEphemeralPk);
|
|
|
|
// Send our ephemeral pk back to server
|
|
sendHbbs(RDProto.encodeRendezvousMsg({
|
|
key_exchange: { keys: [RDCrypto.hbbsBoxPublicKey] }
|
|
}));
|
|
|
|
console.log('[RDConn] Server key exchange complete, hbbs connection encrypted');
|
|
|
|
// Proceed with registration
|
|
sendRegisterPeer();
|
|
}
|
|
|
|
/**
|
|
* Send RegisterPeer to hbbs
|
|
*/
|
|
function sendRegisterPeer() {
|
|
setState(STATE.REGISTERING);
|
|
_serial = Date.now() % 1000000;
|
|
sendHbbs(RDProto.encodeRendezvousMsg({
|
|
register_peer: { id: _myId, serial: _serial }
|
|
}));
|
|
console.log('[RDConn] Sent RegisterPeer, id:', _myId);
|
|
}
|
|
|
|
/**
|
|
* Send RegisterPk to hbbs
|
|
*/
|
|
function sendRegisterPk() {
|
|
const pk = RDCrypto.getSignPublicKey();
|
|
sendHbbs(RDProto.encodeRendezvousMsg({
|
|
register_pk: {
|
|
id: _myId,
|
|
uuid: _myUuid,
|
|
pk: pk
|
|
}
|
|
}));
|
|
console.log('[RDConn] Sent RegisterPk');
|
|
}
|
|
|
|
/**
|
|
* Send PunchHoleRequest to hbbs
|
|
*/
|
|
function sendPunchHole() {
|
|
sendHbbs(RDProto.encodeRendezvousMsg({
|
|
punch_hole_request: {
|
|
id: _targetPeerId,
|
|
nat_type: 0, // UNKNOWN_NAT
|
|
conn_type: 0, // DEFAULT_CONN
|
|
version: '1.3.6',
|
|
force_relay: true // Web clients always use relay
|
|
}
|
|
}));
|
|
console.log('[RDConn] Sent PunchHoleRequest for', _targetPeerId);
|
|
}
|
|
|
|
/**
|
|
* Handle PunchHoleResponse from hbbs
|
|
*/
|
|
async function handlePunchHoleResponse(response) {
|
|
console.log('[RDConn] PunchHoleResponse received');
|
|
|
|
// Check for failure
|
|
if (response.failure) {
|
|
const failures = { 0: 'ID_NOT_EXIST', 2: 'OFFLINE', 3: 'LICENSE_MISMATCH', 4: 'LICENSE_OVERUSE' };
|
|
const reason = failures[response.failure] || `UNKNOWN(${response.failure})`;
|
|
console.error('[RDConn] Punch hole failed:', reason, response.other_failure);
|
|
setState(STATE.FAILED);
|
|
if (_onError) _onError(`Connection failed: ${reason}. ${response.other_failure || ''}`);
|
|
return;
|
|
}
|
|
|
|
// Store peer's Ed25519 public key
|
|
if (response.pk) {
|
|
_peerPk = response.pk;
|
|
RDCrypto.setPeerPublicKey(response.pk);
|
|
}
|
|
|
|
// Web clients always use relay
|
|
setState(STATE.RELAYING);
|
|
await connectRelay();
|
|
}
|
|
|
|
/**
|
|
* Connect to relay (hbbr) via WebSocket
|
|
*/
|
|
function connectRelay() {
|
|
return new Promise((resolve, reject) => {
|
|
const url = buildWsUrl(_serverAddr, '/ws/relay');
|
|
console.log('[RDConn] Connecting to relay at', url);
|
|
|
|
_sessionWs = new WebSocket(url);
|
|
_sessionWs.binaryType = 'arraybuffer';
|
|
|
|
_sessionWs.onopen = () => {
|
|
console.log('[RDConn] Connected to relay');
|
|
// Send RequestRelay
|
|
sendRelay(RDProto.encodeRendezvousMsg({
|
|
request_relay: {
|
|
id: _targetPeerId,
|
|
uuid: _myUuid,
|
|
secure: !!_peerPk,
|
|
conn_type: 0 // DEFAULT_CONN
|
|
}
|
|
}));
|
|
};
|
|
|
|
_sessionWs.onmessage = (event) => {
|
|
handleRelayMessage(new Uint8Array(event.data));
|
|
};
|
|
|
|
_sessionWs.onerror = (e) => {
|
|
console.error('[RDConn] Relay WebSocket error:', e);
|
|
setState(STATE.FAILED);
|
|
if (_onError) _onError('Relay connection failed');
|
|
};
|
|
|
|
_sessionWs.onclose = (e) => {
|
|
console.log('[RDConn] Relay WebSocket closed:', e.code, e.reason);
|
|
if (_state === STATE.CONNECTED) {
|
|
cleanup();
|
|
if (_onClose) _onClose(e.reason || 'Session closed');
|
|
}
|
|
};
|
|
|
|
// Store the resolve for later
|
|
_relayResolve = resolve;
|
|
_relayReject = reject;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle incoming messages from relay
|
|
*/
|
|
function handleRelayMessage(data) {
|
|
// First few messages from relay are RendezvousMessage
|
|
// After key exchange, they become Message (session protocol)
|
|
|
|
let msgData = data;
|
|
let isSessionMsg = RDCrypto.isEncrypted();
|
|
|
|
if (isSessionMsg) {
|
|
// Try to decrypt as session message
|
|
msgData = RDCrypto.decrypt(data);
|
|
if (!msgData) {
|
|
console.error('[RDConn] Failed to decrypt session message');
|
|
return;
|
|
}
|
|
handleSessionMessage(msgData);
|
|
} else {
|
|
// Still in rendezvous protocol
|
|
try {
|
|
const rvMsg = RDProto.decodeRendezvousMsg(msgData);
|
|
handleRelayRendezvous(rvMsg);
|
|
} catch (e) {
|
|
// Maybe it's a session message without encryption
|
|
try {
|
|
handleSessionMessage(msgData);
|
|
} catch (e2) {
|
|
console.error('[RDConn] Failed to parse message:', e, e2);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle RendezvousMessage from relay
|
|
*/
|
|
function handleRelayRendezvous(rvMsg) {
|
|
const field = getRvField(rvMsg);
|
|
|
|
switch (field) {
|
|
case 'relay_response':
|
|
handleRelayResponse(rvMsg.relay_response);
|
|
break;
|
|
|
|
case 'punch_hole':
|
|
// Server is telling us about an incoming connection
|
|
console.log('[RDConn] PunchHole received (unexpected for web client)');
|
|
break;
|
|
|
|
default:
|
|
console.log('[RDConn] Unhandled relay rendezvous:', field);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle RelayResponse from hbbr
|
|
*/
|
|
async function handleRelayResponse(response) {
|
|
console.log('[RDConn] RelayResponse received');
|
|
|
|
if (response.refuse_reason) {
|
|
console.error('[RDConn] Relay refused:', response.refuse_reason);
|
|
setState(STATE.FAILED);
|
|
if (_onError) _onError(`Relay refused: ${response.refuse_reason}`);
|
|
return;
|
|
}
|
|
|
|
// If we got the peer's pk from relay response, use it
|
|
if (response.pk) {
|
|
_peerPk = response.pk;
|
|
RDCrypto.setPeerPublicKey(response.pk);
|
|
}
|
|
|
|
if (response.uuid) {
|
|
_relayUuid = response.uuid;
|
|
console.log('[RDConn] Relay UUID:', response.uuid);
|
|
}
|
|
|
|
setState(STATE.KEY_EXCHANGE);
|
|
console.log('[RDConn] Waiting for key exchange from peer...');
|
|
// The actual session messages will come through the same relay WebSocket
|
|
if (_relayResolve) _relayResolve();
|
|
}
|
|
|
|
/**
|
|
* Handle session protocol Message from peer
|
|
*/
|
|
function handleSessionMessage(data) {
|
|
const msg = RDProto.decodeMsg(data);
|
|
const field = getMsgField(msg);
|
|
|
|
switch (field) {
|
|
case 'signed_id':
|
|
handleSignedId(msg.signed_id);
|
|
break;
|
|
|
|
case 'hash':
|
|
handleHashChallenge(msg.hash);
|
|
break;
|
|
|
|
case 'login_response':
|
|
handleLoginResponse(msg.login_response);
|
|
break;
|
|
|
|
case 'video_frame':
|
|
if (_onVideoFrame) _onVideoFrame(msg.video_frame);
|
|
break;
|
|
|
|
case 'audio_frame':
|
|
if (_onAudioFrame) _onAudioFrame(msg.audio_frame);
|
|
break;
|
|
|
|
case 'cursor_data':
|
|
if (_onCursorData) _onCursorData(msg.cursor_data);
|
|
break;
|
|
|
|
case 'cursor_position':
|
|
if (_onCursorPosition) _onCursorPosition(msg.cursor_position);
|
|
break;
|
|
|
|
case 'cursor_id':
|
|
console.log('[RDConn] Cursor ID:', msg.cursor_id.toString());
|
|
break;
|
|
|
|
case 'misc':
|
|
handleMiscEvent(msg.misc);
|
|
break;
|
|
|
|
case 'clipboard':
|
|
if (_onClipboard) _onClipboard(msg.clipboard);
|
|
break;
|
|
|
|
case 'test_delay':
|
|
handleTestDelay(msg.test_delay);
|
|
break;
|
|
|
|
case 'message_box':
|
|
handleMessageBox(msg.message_box);
|
|
break;
|
|
|
|
case 'peer_info':
|
|
if (_onPeerInfo) _onPeerInfo(msg.peer_info);
|
|
break;
|
|
|
|
default:
|
|
console.log('[RDConn] Session message:', field);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle SignedId (key exchange step 1)
|
|
*/
|
|
function handleSignedId(signedId) {
|
|
console.log('[RDConn] Received SignedId, performing key exchange...');
|
|
try {
|
|
// Verify Ed25519 signature and extract IdPk
|
|
const idPk = RDCrypto.verifySignedId(signedId.id);
|
|
|
|
// Create our PublicKey response
|
|
const keyResponse = RDCrypto.createKeyExchangeResponse(idPk.pk);
|
|
|
|
// Send PublicKey message
|
|
sendSession(RDProto.encodeMsg({
|
|
public_key: keyResponse
|
|
}));
|
|
|
|
console.log('[RDConn] Key exchange complete, waiting for auth challenge...');
|
|
setState(STATE.AUTHENTICATING);
|
|
} catch (e) {
|
|
console.error('[RDConn] Key exchange failed:', e);
|
|
setState(STATE.FAILED);
|
|
if (_onError) _onError(`Key exchange failed: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle Hash challenge (authentication step 1)
|
|
*/
|
|
function handleHashChallenge(hash) {
|
|
console.log('[RDConn] Received hash challenge, requesting password...');
|
|
if (_passwordCallback) {
|
|
_passwordCallback(hash, async (password) => {
|
|
if (password === null || password === undefined) {
|
|
// User cancelled
|
|
sendSession(RDProto.encodeMsg({}));
|
|
close();
|
|
return;
|
|
}
|
|
const hashedPw = await RDCrypto.hashPassword(hash.salt, password);
|
|
sendSession(RDProto.encodeMsg(
|
|
RDProto.createPasswordLogin(_myId, _myName, hashedPw, 'Web', '1.3.6')
|
|
));
|
|
});
|
|
} else {
|
|
console.log('[RDConn] No password callback set, connecting without password');
|
|
sendSession(RDProto.encodeMsg(
|
|
RDProto.createLoginRequest(_myId, _myName, new Uint8Array(0), 'Web', '1.3.6')
|
|
));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle LoginResponse (authentication result)
|
|
*/
|
|
function handleLoginResponse(response) {
|
|
if (response.error) {
|
|
console.error('[RDConn] Login failed:', response.error);
|
|
setState(STATE.FAILED);
|
|
if (_onError) _onError(`Authentication failed: ${response.error}`);
|
|
return;
|
|
}
|
|
|
|
if (response.peer_info) {
|
|
_peerPlatform = response.peer_info.platform;
|
|
_peerVersion = response.peer_info.version;
|
|
console.log('[RDConn] Login successful! Peer:', response.peer_info.hostname,
|
|
'Platform:', _peerPlatform, 'Version:', _peerVersion);
|
|
|
|
if (_onPeerInfo) _onPeerInfo(response.peer_info);
|
|
setState(STATE.CONNECTED);
|
|
|
|
// Start keepalive
|
|
startKeepalive();
|
|
startLatencyTest();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle Misc event
|
|
*/
|
|
function handleMiscEvent(misc) {
|
|
const field = getMiscField(misc);
|
|
|
|
switch (field) {
|
|
case 'close_reason':
|
|
console.log('[RDConn] Close reason:', misc.close_reason);
|
|
cleanup();
|
|
if (_onClose) _onClose(misc.close_reason);
|
|
break;
|
|
|
|
case 'permission_info':
|
|
console.log('[RDConn] Permission:',
|
|
misc.permission_info.permission,
|
|
misc.permission_info.enabled ? 'granted' : 'revoked');
|
|
break;
|
|
|
|
case 'switch_display':
|
|
console.log('[RDConn] Switch display:', misc.switch_display.display,
|
|
misc.switch_display.width, 'x', misc.switch_display.height);
|
|
break;
|
|
|
|
case 'audio_format':
|
|
console.log('[RDConn] Audio format:',
|
|
misc.audio_format.sample_rate, 'Hz,',
|
|
misc.audio_format.channels, 'channels');
|
|
break;
|
|
|
|
case 'supported_encoding':
|
|
console.log('[RDConn] Server encoding support:',
|
|
'h264:', misc.supported_encoding.h264,
|
|
'h265:', misc.supported_encoding.h265,
|
|
'vp8:', misc.supported_encoding.vp8,
|
|
'av1:', misc.supported_encoding.av1);
|
|
break;
|
|
|
|
case 'chat_message':
|
|
console.log('[RDConn] Chat:', misc.chat_message.text);
|
|
break;
|
|
|
|
case 'elevation_request':
|
|
console.log('[RDConn] Elevation request');
|
|
break;
|
|
|
|
case 'refresh_video':
|
|
console.log('[RDConn] Refresh video requested');
|
|
break;
|
|
|
|
case 'back_notification':
|
|
console.log('[RDConn] Back notification:', misc.back_notification);
|
|
break;
|
|
|
|
default:
|
|
// Pass to app callback
|
|
if (_onMiscEvent) _onMiscEvent(field, misc);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle MessageBox from peer
|
|
*/
|
|
function handleMessageBox(msgBox) {
|
|
console.log('[RDConn] MessageBox:', msgBox.msgtype, msgBox.title, msgBox.text);
|
|
if (_onMiscEvent) _onMiscEvent('message_box', msgBox);
|
|
}
|
|
|
|
/**
|
|
* Handle TestDelay (latency measurement)
|
|
*/
|
|
function handleTestDelay(testDelay) {
|
|
if (testDelay.from_client) return; // Don't echo our own
|
|
|
|
const latency = testDelay.last_delay;
|
|
console.log('[RDConn] Latency:', latency, 'ms');
|
|
}
|
|
|
|
/**
|
|
* Send data to hbbs signaling server
|
|
*/
|
|
function sendHbbs(data) {
|
|
if (_hbbsWs && _hbbsWs.readyState === WebSocket.OPEN) {
|
|
if (RDCrypto.isHbbsEncrypted()) {
|
|
data = RDCrypto.hbbsEncrypt(data);
|
|
}
|
|
_hbbsWs.send(data);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send data through session (relay) WebSocket
|
|
*/
|
|
function sendSession(data) {
|
|
if (_sessionWs && _sessionWs.readyState === WebSocket.OPEN) {
|
|
let sendData = data;
|
|
if (RDCrypto.isEncrypted()) {
|
|
sendData = RDCrypto.encrypt(data);
|
|
}
|
|
_sessionWs.send(sendData);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Connect to a specific peer
|
|
*/
|
|
function connectToPeer(peerId) {
|
|
_targetPeerId = peerId;
|
|
|
|
if (_state === STATE.PUNCHING ||
|
|
_state === STATE.REGISTERING ||
|
|
_state === STATE.CONNECTING) {
|
|
// Already connected to hbbs, just send punch hole
|
|
sendPunchHole();
|
|
} else if (_state === STATE.CONNECTED) {
|
|
// Already in a session, close it first
|
|
close();
|
|
setTimeout(() => connect(_serverAddr), 500);
|
|
// After reconnect, we'll need to punch hole again
|
|
// The password callback etc. should still be set
|
|
} else {
|
|
// Reconnect to server
|
|
connect(_serverAddr, {
|
|
clientId: _myId,
|
|
clientName: _myName,
|
|
uuid: _myUuid,
|
|
tls: _useTls,
|
|
serverKey: _serverKey,
|
|
onPassword: _passwordCallback,
|
|
on2FA: _2faCallback,
|
|
onPeerInfo: _onPeerInfo,
|
|
onVideoFrame: _onVideoFrame,
|
|
onAudioFrame: _onAudioFrame,
|
|
onCursorData: _onCursorData,
|
|
onCursorPosition: _onCursorPosition,
|
|
onMiscEvent: _onMiscEvent,
|
|
onClipboard: _onClipboard,
|
|
onClose: _onClose,
|
|
onStateChange: _onStateChange,
|
|
onError: _onError,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send a 2FA code
|
|
*/
|
|
function send2FACode(code) {
|
|
if (_state === STATE.AUTHENTICATING) {
|
|
sendSession(RDProto.encodeMsg({
|
|
auth_2fa: { code: code }
|
|
}));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send a mouse event to the peer
|
|
*/
|
|
function sendMouseEvent(mask, x, y, modifiers = []) {
|
|
sendSession(RDProto.encodeMsg(RDProto.createMouseEvent(mask, x, y, modifiers)));
|
|
}
|
|
|
|
/**
|
|
* Send a key event to the peer
|
|
*/
|
|
function sendKeyEvent(controlKey, down, press = false, modifiers = []) {
|
|
sendSession(RDProto.encodeMsg(RDProto.createKeyEvent(controlKey, down, press, modifiers)));
|
|
}
|
|
|
|
/**
|
|
* Send a character key event to the peer
|
|
*/
|
|
function sendCharEvent(chr, down, press = false, modifiers = []) {
|
|
sendSession(RDProto.encodeMsg(RDProto.createCharKeyEvent(chr, down, press, modifiers)));
|
|
}
|
|
|
|
/**
|
|
* Send clipboard text to peer
|
|
*/
|
|
function sendClipboard(text) {
|
|
sendSession(RDProto.encodeMsg(RDProto.createClipboard(text)));
|
|
}
|
|
|
|
/**
|
|
* Send Ctrl+Alt+Del
|
|
*/
|
|
function sendCtrlAltDel() {
|
|
sendSession(RDProto.encodeMsg(RDProto.createKeyEvent(100, true, true)));
|
|
setTimeout(() => sendSession(RDProto.encodeMsg(RDProto.createKeyEvent(100, false, false))), 50);
|
|
}
|
|
|
|
/**
|
|
* Request display switch
|
|
*/
|
|
function sendSwitchDisplay(display) {
|
|
sendSession(RDProto.encodeMsg({
|
|
misc: { switch_display: { display: display } }
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Lock remote screen
|
|
*/
|
|
function sendLockScreen() {
|
|
sendSession(RDProto.encodeMsg(RDProto.createKeyEvent(101, true, true)));
|
|
}
|
|
|
|
/**
|
|
* Start keepalive interval
|
|
*/
|
|
function startKeepalive() {
|
|
stopKeepalive();
|
|
_keepAliveInterval = setInterval(() => {
|
|
if (_state === STATE.CONNECTED) {
|
|
// Send empty message as keepalive
|
|
sendSession(new Uint8Array(0));
|
|
}
|
|
}, 30000);
|
|
}
|
|
|
|
/**
|
|
* Stop keepalive
|
|
*/
|
|
function stopKeepalive() {
|
|
if (_keepAliveInterval) {
|
|
clearInterval(_keepAliveInterval);
|
|
_keepAliveInterval = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start latency test interval
|
|
*/
|
|
function startLatencyTest() {
|
|
stopLatencyTest();
|
|
_latencyInterval = setInterval(() => {
|
|
if (_state === STATE.CONNECTED) {
|
|
sendSession(RDProto.encodeMsg(RDProto.createTestDelay(true)));
|
|
}
|
|
}, 10000);
|
|
}
|
|
|
|
/**
|
|
* Stop latency test
|
|
*/
|
|
function stopLatencyTest() {
|
|
if (_latencyInterval) {
|
|
clearInterval(_latencyInterval);
|
|
_latencyInterval = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clean up connection resources
|
|
*/
|
|
function cleanup() {
|
|
stopKeepalive();
|
|
stopLatencyTest();
|
|
RDCrypto.reset();
|
|
RDCrypto.resetHbbs();
|
|
}
|
|
|
|
/**
|
|
* Close the connection
|
|
*/
|
|
function close() {
|
|
cleanup();
|
|
if (_hbbsWs) {
|
|
try { _hbbsWs.close(); } catch (e) {}
|
|
_hbbsWs = null;
|
|
}
|
|
if (_sessionWs) {
|
|
try { _sessionWs.close(); } catch (e) {}
|
|
_sessionWs = null;
|
|
}
|
|
setState(STATE.CLOSED);
|
|
}
|
|
|
|
/**
|
|
* Set connection state
|
|
*/
|
|
function setState(newState) {
|
|
const oldState = _state;
|
|
_state = newState;
|
|
console.log('[RDConn] State:', oldState, '->', newState);
|
|
if (_onStateChange) _onStateChange(newState, oldState);
|
|
}
|
|
|
|
/**
|
|
* Get the active field from a RendezvousMessage
|
|
*/
|
|
function getRvField(msg) {
|
|
const fields = ['register_peer', 'register_peer_response', 'punch_hole_request',
|
|
'punch_hole', 'punch_hole_sent', 'punch_hole_response', 'fetch_local_addr',
|
|
'local_addr', 'configure_update', 'register_pk', 'register_pk_response',
|
|
'software_update', 'request_relay', 'relay_response', 'test_nat_request',
|
|
'test_nat_response', 'peer_discovery', 'online_request', 'online_response',
|
|
'key_exchange', 'hc', 'http_proxy_request', 'http_proxy_response'];
|
|
for (const f of fields) {
|
|
if (msg[f] != null && !(typeof msg[f] === 'object' && Object.keys(msg[f]).length === 0 && !ArrayBuffer.isView(msg[f]))) {
|
|
return f;
|
|
}
|
|
}
|
|
// Check for bytes/number fields
|
|
if (msg.hc !== undefined && msg.hc !== null) return 'hc';
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Get the active field from a Message
|
|
*/
|
|
function getMsgField(msg) {
|
|
const fields = ['signed_id', 'public_key', 'test_delay', 'video_frame',
|
|
'login_request', 'login_response', 'hash', 'mouse_event', 'audio_frame',
|
|
'cursor_data', 'cursor_position', 'key_event', 'clipboard',
|
|
'file_action', 'file_response', 'misc', 'cliprdr', 'message_box',
|
|
'peer_info', 'pointer_device_event', 'auth_2fa', 'multi_clipboards',
|
|
'screenshot_request', 'screenshot_response', 'terminal_action',
|
|
'terminal_response', 'switch_sides_response', 'voice_call_request',
|
|
'voice_call_response'];
|
|
for (const f of fields) {
|
|
if (msg[f] != null) {
|
|
if (f === 'cursor_id' && msg[f] === 0n) continue; // Skip default bigint
|
|
if (typeof msg[f] === 'object' && !ArrayBuffer.isView(msg[f]) &&
|
|
!(msg[f] instanceof Uint8Array) && !(msg[f] instanceof ArrayBuffer) &&
|
|
Object.keys(msg[f]).length === 0) continue;
|
|
if (typeof msg[f] === 'number' && msg[f] === 0 && f === 'cursor_id') continue;
|
|
return f;
|
|
}
|
|
}
|
|
// Check cursor_id specifically (bigint)
|
|
if (msg.cursor_id && msg.cursor_id !== 0n) return 'cursor_id';
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Get the active field from a Misc message
|
|
*/
|
|
function getMiscField(misc) {
|
|
const fields = ['chat_message', 'switch_display', 'permission_info', 'option',
|
|
'audio_format', 'close_reason', 'refresh_video', 'video_received',
|
|
'back_notification', 'restart_remote_device', 'uac', 'foreground_window_elevated',
|
|
'stop_service', 'elevation_request', 'elevation_response',
|
|
'portable_service_running', 'switch_sides_request', 'switch_back',
|
|
'change_resolution', 'plugin_request', 'plugin_failure',
|
|
'full_speed_fps', 'auto_adjust_fps', 'client_record_status',
|
|
'capture_displays', 'refresh_video_display', 'toggle_virtual_display',
|
|
'toggle_privacy_mode', 'supported_encoding', 'selected_sid',
|
|
'change_display_resolution', 'message_query', 'follow_current_display'];
|
|
for (const f of fields) {
|
|
if (misc[f] != null && misc[f] !== false && misc[f] !== '') {
|
|
return f;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Hex string to Uint8Array
|
|
*/
|
|
function hexToBytes(hex) {
|
|
const bytes = new Uint8Array(hex.length / 2);
|
|
for (let i = 0; i < hex.length; i += 2) {
|
|
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
return {
|
|
STATE,
|
|
connect,
|
|
connectToPeer,
|
|
close,
|
|
sendMouseEvent,
|
|
sendKeyEvent,
|
|
sendCharEvent,
|
|
sendClipboard,
|
|
sendCtrlAltDel,
|
|
sendSwitchDisplay,
|
|
sendLockScreen,
|
|
send2FACode,
|
|
get state() { return _state; },
|
|
get myId() { return _myId; },
|
|
get myName() { return _myName; },
|
|
get peerPlatform() { return _peerPlatform; },
|
|
get isConnected() { return _state === STATE.CONNECTED; },
|
|
};
|
|
})();
|