wraith/frontend/composables/useRdp.ts

211 lines
6.6 KiB
TypeScript

import Guacamole from 'guacamole-common-js'
import { useAuthStore } from '~/stores/auth.store'
import { useSessionStore } from '~/stores/session.store'
/**
* Creates a Guacamole-compatible tunnel that speaks JSON over WebSocket.
* Does NOT extend Guacamole.Tunnel (its constructor assigns no-op instance
* properties that shadow subclass methods). Instead, creates a base tunnel
* and overwrites the methods directly.
*/
function createJsonWsTunnel(wsUrl: string, connectMsg: object) {
const tunnel = new Guacamole.Tunnel()
let ws: WebSocket | null = null
// Custom callbacks for our JSON protocol layer
let onConnected: ((hostId: number, hostName: string) => void) | null = null
let onDisconnected: ((reason: string) => void) | null = null
let onGatewayError: ((message: string) => void) | null = null
function dispatchInstructions(raw: string) {
if (!tunnel.oninstruction) return
let remaining = raw
while (remaining.length > 0) {
const semicolonIdx = remaining.indexOf(';')
if (semicolonIdx === -1) break
const instruction = remaining.substring(0, semicolonIdx)
remaining = remaining.substring(semicolonIdx + 1)
if (!instruction) continue
const parts: string[] = []
let pos = 0
while (pos < instruction.length) {
const dotIdx = instruction.indexOf('.', pos)
if (dotIdx === -1) break
const len = parseInt(instruction.substring(pos, dotIdx), 10)
if (isNaN(len)) break
parts.push(instruction.substring(dotIdx + 1, dotIdx + 1 + len))
pos = dotIdx + 1 + len + 1
}
if (parts.length > 0) {
tunnel.oninstruction(parts[0], parts.slice(1))
}
}
}
// Override the no-op instance methods
tunnel.connect = (_data?: string) => {
console.log('[RDP] Tunnel opening WebSocket:', wsUrl)
ws = new WebSocket(wsUrl)
ws.onopen = () => {
console.log('[RDP] WebSocket open, sending connect handshake')
ws!.send(JSON.stringify(connectMsg))
}
ws.onmessage = (event: MessageEvent) => {
const msg = JSON.parse(event.data as string)
switch (msg.type) {
case 'connected':
onConnected?.(msg.hostId, msg.hostName)
break
case 'guac':
dispatchInstructions(msg.instruction)
break
case 'error':
onGatewayError?.(msg.message)
tunnel.onerror?.(new Guacamole.Status(Guacamole.Status.Code.SERVER_ERROR, msg.message))
break
case 'disconnected':
onDisconnected?.(msg.reason)
break
}
}
ws.onclose = (event: CloseEvent) => {
console.log('[RDP] WebSocket closed, code:', event.code)
if (event.code === 4001) {
tunnel.onerror?.(new Guacamole.Status(Guacamole.Status.Code.CLIENT_FORBIDDEN, 'Unauthorized'))
}
onDisconnected?.('WebSocket closed')
}
ws.onerror = () => {
console.error('[RDP] WebSocket error')
tunnel.onerror?.(new Guacamole.Status(Guacamole.Status.Code.SERVER_ERROR, 'WebSocket error'))
}
}
tunnel.disconnect = () => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.close()
}
ws = null
}
tunnel.sendMessage = (...elements: any[]) => {
if (ws?.readyState === WebSocket.OPEN) {
const instruction = elements.map((e: any) => {
const s = String(e)
return `${s.length}.${s}`
}).join(',') + ';'
ws.send(JSON.stringify({ type: 'guac', instruction }))
}
}
return {
tunnel,
get onConnected() { return onConnected },
set onConnected(fn) { onConnected = fn },
get onDisconnected() { return onDisconnected },
set onDisconnected(fn) { onDisconnected = fn },
get onGatewayError() { return onGatewayError },
set onGatewayError(fn) { onGatewayError = fn },
}
}
// ─── Composable ────────────────────────────────────────────────────────────────
export function useRdp() {
const auth = useAuthStore()
const sessions = useSessionStore()
function connectRdp(
container: HTMLElement,
hostId: number,
hostName: string,
color: string | null,
pendingSessionId: string,
options?: { width?: number; height?: number },
) {
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
const wsUrl = `${proto}://${location.host}/api/ws/rdp?token=${auth.token}`
const width = options?.width || container.clientWidth || 1920
const height = options?.height || container.clientHeight || 1080
console.log(`[RDP] Connecting to ${wsUrl} for hostId=${hostId} (${width}x${height})`)
const wrapper = createJsonWsTunnel(wsUrl, {
type: 'connect',
hostId,
width,
height,
})
const client = new Guacamole.Client(wrapper.tunnel)
const sessionId = pendingSessionId
wrapper.onConnected = (_resolvedHostId: number, resolvedHostName: string) => {
console.log(`[RDP] Connected to ${resolvedHostName}`)
sessions.replaceSession(sessionId, {
key: sessionId,
id: sessionId,
hostId,
hostName: resolvedHostName || hostName,
protocol: 'rdp',
color,
active: true,
})
}
wrapper.onDisconnected = (reason: string) => {
console.log(`[RDP] Disconnected: ${reason}`)
sessions.removeSession(sessionId)
client.disconnect()
}
wrapper.onGatewayError = (message: string) => {
console.error('[RDP] Gateway error:', message)
}
// Attach Guacamole display element to container
const displayEl = client.getDisplay().getElement()
container.appendChild(displayEl)
// Mouse input
const mouse = new Guacamole.Mouse(displayEl)
mouse.onEach(
['mousedown', 'mousemove', 'mouseup'],
(e: Guacamole.Mouse.Event) => {
client.sendMouseState(e.state)
},
)
// 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
client.connect()
function disconnect() {
keyboard.onkeydown = null
keyboard.onkeyup = null
client.disconnect()
sessions.removeSession(sessionId)
}
function sendClipboardText(text: string) {
const stream = client.createClipboardStream('text/plain')
const writer = new Guacamole.StringWriter(stream)
writer.sendText(text)
writer.sendEnd()
}
return { client, sessionId, disconnect, sendClipboardText }
}
return { connectRdp }
}