fix(rdp): override Guacamole.Tunnel instance methods, fix sendMessage encoding

This commit is contained in:
Vantz Stockwell 2026-03-14 04:39:44 -04:00
parent e3a978b639
commit 76db0a6936

View File

@ -13,23 +13,15 @@ import { useSessionStore } from '~/stores/session.store'
* - This tunnel bridges the two: guacamole-common-js sees raw Guacamole
* instructions while our gateway sees consistent JSON messages.
*
* Protocol:
* Browser Gateway: { type: 'connect', hostId, width, height, ... }
* Browser Gateway: { type: 'guac', instruction: '<raw guac instruction>' }
* Gateway Browser: { type: 'connected', hostId, hostName }
* Gateway Browser: { type: 'guac', instruction: '<raw guac instruction>' }
* Gateway Browser: { type: 'error', message: '...' }
* Gateway Browser: { type: 'disconnected', reason: '...' }
* IMPORTANT: Guacamole.Tunnel assigns connect/sendMessage/disconnect as
* instance properties in its constructor (not prototype methods). We MUST
* reassign them in our constructor AFTER super() to override the no-ops.
*/
class JsonWsTunnel extends Guacamole.Tunnel {
private ws: WebSocket | null = null
private readonly wsUrl: string
private readonly connectMsg: object
// Callbacks set by Guacamole.Client after we're constructed
oninstruction: ((opcode: string, args: string[]) => void) | null = null
onerror: ((status: Guacamole.Status) => void) | null = null
// Expose for external callers (connected event, disconnect action)
onConnected: ((hostId: number, hostName: string) => void) | null = null
onDisconnected: ((reason: string) => void) | null = null
@ -39,15 +31,19 @@ class JsonWsTunnel extends Guacamole.Tunnel {
super()
this.wsUrl = wsUrl
this.connectMsg = connectMsg
// Override instance methods set by Guacamole.Tunnel constructor
this.connect = this._connect.bind(this)
this.disconnect = this._disconnect.bind(this)
this.sendMessage = this._sendMessage.bind(this)
}
connect(_data?: string) {
private _connect(_data?: string) {
console.log('[RDP] Tunnel opening WebSocket:', this.wsUrl)
this.ws = new WebSocket(this.wsUrl)
this.ws.onopen = () => {
console.log('[RDP] WebSocket open, sending connect handshake')
// Step 1: send our JSON connect handshake
this.ws!.send(JSON.stringify(this.connectMsg))
}
@ -56,15 +52,10 @@ class JsonWsTunnel extends Guacamole.Tunnel {
switch (msg.type) {
case 'connected':
// Gateway completed the guacd SELECT→CONNECT handshake.
// Notify RdpCanvas so it can finalize the session store entry.
this.onConnected?.(msg.hostId, msg.hostName)
break
case 'guac': {
// Raw Guacamole instruction from guacd — parse and dispatch to client.
// Instructions arrive as: "4.size,4.1024,3.768;" or multiple batched.
// We dispatch each complete instruction individually.
const instruction: string = msg.instruction
this._dispatchInstructions(instruction)
break
@ -93,27 +84,28 @@ class JsonWsTunnel extends Guacamole.Tunnel {
}
}
disconnect() {
private _disconnect() {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.close()
}
this.ws = null
}
sendMessage(message: string) {
// Guacamole.Client calls this with raw Guacamole instruction strings.
// Wrap in JSON envelope and forward to gateway → guacd.
private _sendMessage(...elements: any[]) {
// Guacamole.Client calls sendMessage(opcode, arg1, arg2, ...) with individual elements.
// Encode into Guacamole wire format: "<len>.<value>,<len>.<value>,...;"
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: 'guac', instruction: message }))
const instruction = elements.map((e: any) => {
const s = String(e)
return `${s.length}.${s}`
}).join(',') + ';'
this.ws.send(JSON.stringify({ type: 'guac', instruction }))
}
}
/**
* Parse one or more Guacamole instructions from a raw buffer string
* and call oninstruction for each.
*
* Guacamole instruction format: "<len>.<opcode>,<len>.<arg1>,...;"
* Multiple instructions may arrive in one data chunk.
*/
private _dispatchInstructions(raw: string) {
if (!this.oninstruction) return
@ -121,14 +113,13 @@ class JsonWsTunnel extends Guacamole.Tunnel {
let remaining = raw
while (remaining.length > 0) {
const semicolonIdx = remaining.indexOf(';')
if (semicolonIdx === -1) break // incomplete instruction — guacd shouldn't send partials but be safe
if (semicolonIdx === -1) break
const instruction = remaining.substring(0, semicolonIdx)
remaining = remaining.substring(semicolonIdx + 1)
if (!instruction) continue
// Parse length-prefixed fields
const parts: string[] = []
let pos = 0
while (pos < instruction.length) {
@ -138,7 +129,7 @@ class JsonWsTunnel extends Guacamole.Tunnel {
if (isNaN(len)) break
const value = instruction.substring(dotIdx + 1, dotIdx + 1 + len)
parts.push(value)
pos = dotIdx + 1 + len + 1 // skip comma separator
pos = dotIdx + 1 + len + 1
}
if (parts.length > 0) {
@ -181,13 +172,10 @@ export function useRdp() {
const client = new Guacamole.Client(tunnel)
// Session ID is passed in by the caller (the pending session created in connectHost)
const sessionId = pendingSessionId
// Wire tunnel callbacks
tunnel.onConnected = (_resolvedHostId: number, resolvedHostName: string) => {
console.log(`[RDP] Connected to ${resolvedHostName}`)
// Update the pending session with the resolved host name
sessions.replaceSession(sessionId, {
key: sessionId,
id: sessionId,
@ -222,13 +210,12 @@ export function useRdp() {
},
)
// Keyboard input — attach to document so key events fire even when
// focus is on other elements (toolbar buttons, etc.)
// Keyboard input
const keyboard = new Guacamole.Keyboard(document)
keyboard.onkeydown = (keysym: number) => client.sendKeyEvent(1, keysym)
keyboard.onkeyup = (keysym: number) => client.sendKeyEvent(0, keysym)
// Initiate the WebSocket connection (triggers connect handshake)
// Initiate the WebSocket connection
client.connect()
function disconnect() {