feat: RDP frontend — Guacamole client with custom JSON WebSocket tunnel
- useRdp.ts: JsonWsTunnel class extends Guacamole.Tunnel to bridge guacamole-common-js (expects raw protocol) with our JSON gateway (consistent with SSH/SFTP message envelope pattern). Parses length-prefixed Guacamole instructions, dispatches to Guacamole.Client. Handles mouse/keyboard input, clipboard send, and session lifecycle. - RdpCanvas.vue: full-size container that mounts the Guacamole display canvas. Calls useRdp().connectRdp() on mount, cleans up on unmount. Exposes sendClipboard() and disconnect() for toolbar integration. - RdpToolbar.vue: auto-hiding floating toolbar (3s idle timeout) with clipboard paste dialog, fullscreen toggle (HTML5 Fullscreen API), settings panel stub, and disconnect button. - SessionContainer.vue: renders RdpCanvas + RdpToolbar when session.protocol === 'rdp', replacing the Phase 3 placeholder. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
5d75869bb4
commit
e2e03be2dd
66
frontend/components/rdp/RdpCanvas.vue
Normal file
66
frontend/components/rdp/RdpCanvas.vue
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
import { useRdp } from '~/composables/useRdp'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
hostId: number
|
||||||
|
hostName: string
|
||||||
|
color?: string | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const container = ref<HTMLDivElement | null>(null)
|
||||||
|
const { connectRdp } = useRdp()
|
||||||
|
|
||||||
|
let rdpSession: ReturnType<ReturnType<typeof useRdp>['connectRdp']> | null = null
|
||||||
|
|
||||||
|
// Expose to parent (RdpToolbar uses these)
|
||||||
|
const sendClipboard = (text: string) => rdpSession?.sendClipboardText(text)
|
||||||
|
const disconnect = () => rdpSession?.disconnect()
|
||||||
|
|
||||||
|
defineExpose({ sendClipboard, disconnect })
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!container.value) return
|
||||||
|
|
||||||
|
rdpSession = connectRdp(
|
||||||
|
container.value,
|
||||||
|
props.hostId,
|
||||||
|
props.hostName,
|
||||||
|
props.color ?? null,
|
||||||
|
{
|
||||||
|
width: container.value.clientWidth,
|
||||||
|
height: container.value.clientHeight,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
rdpSession?.disconnect()
|
||||||
|
rdpSession = null
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- Full-size container for the Guacamole display canvas.
|
||||||
|
The Guacamole client appends its own <canvas> element here. -->
|
||||||
|
<div
|
||||||
|
ref="container"
|
||||||
|
class="rdp-canvas-container"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.rdp-canvas-container {
|
||||||
|
@apply absolute inset-0 bg-gray-950 overflow-hidden cursor-default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Guacamole appends a display div; make it fill the container */
|
||||||
|
.rdp-canvas-container :deep(> div) {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rdp-canvas-container :deep(canvas) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
226
frontend/components/rdp/RdpToolbar.vue
Normal file
226
frontend/components/rdp/RdpToolbar.vue
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { Monitor, Clipboard, Maximize2, Minimize2, X, Settings } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
hostName: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
disconnect: []
|
||||||
|
sendClipboard: [text: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Toolbar visibility — auto-hide after idle
|
||||||
|
const toolbarVisible = ref(true)
|
||||||
|
let hideTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
function showToolbar() {
|
||||||
|
toolbarVisible.value = true
|
||||||
|
if (hideTimer) clearTimeout(hideTimer)
|
||||||
|
hideTimer = setTimeout(() => {
|
||||||
|
toolbarVisible.value = false
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clipboard dialog
|
||||||
|
const clipboardOpen = ref(false)
|
||||||
|
const clipboardText = ref('')
|
||||||
|
|
||||||
|
function openClipboard() {
|
||||||
|
clipboardOpen.value = true
|
||||||
|
clipboardText.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function pasteClipboard() {
|
||||||
|
if (clipboardText.value) {
|
||||||
|
emit('sendClipboard', clipboardText.value)
|
||||||
|
}
|
||||||
|
clipboardOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fullscreen
|
||||||
|
const isFullscreen = ref(false)
|
||||||
|
|
||||||
|
async function toggleFullscreen() {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
await document.documentElement.requestFullscreen()
|
||||||
|
isFullscreen.value = true
|
||||||
|
} else {
|
||||||
|
await document.exitFullscreen()
|
||||||
|
isFullscreen.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings panel
|
||||||
|
const settingsOpen = ref(false)
|
||||||
|
|
||||||
|
// Disconnect
|
||||||
|
function disconnect() {
|
||||||
|
emit('disconnect')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- Floating toolbar — shows on mouse movement, auto-hides after 3s -->
|
||||||
|
<div
|
||||||
|
class="rdp-toolbar-wrapper"
|
||||||
|
@mousemove="showToolbar"
|
||||||
|
>
|
||||||
|
<Transition name="toolbar-slide">
|
||||||
|
<div
|
||||||
|
v-if="toolbarVisible"
|
||||||
|
class="rdp-toolbar"
|
||||||
|
>
|
||||||
|
<!-- Host name label -->
|
||||||
|
<div class="flex items-center gap-2 text-gray-300 text-sm font-medium min-w-0">
|
||||||
|
<Monitor class="w-4 h-4 text-wraith-400 shrink-0" />
|
||||||
|
<span class="truncate max-w-36">{{ props.hostName }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="h-4 w-px bg-gray-600 mx-1" />
|
||||||
|
|
||||||
|
<!-- Clipboard -->
|
||||||
|
<button
|
||||||
|
class="toolbar-btn"
|
||||||
|
title="Send clipboard text"
|
||||||
|
@click="openClipboard"
|
||||||
|
>
|
||||||
|
<Clipboard class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Fullscreen toggle -->
|
||||||
|
<button
|
||||||
|
class="toolbar-btn"
|
||||||
|
:title="isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'"
|
||||||
|
@click="toggleFullscreen"
|
||||||
|
>
|
||||||
|
<Maximize2 v-if="!isFullscreen" class="w-4 h-4" />
|
||||||
|
<Minimize2 v-else class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Settings -->
|
||||||
|
<button
|
||||||
|
class="toolbar-btn"
|
||||||
|
title="RDP settings"
|
||||||
|
@click="settingsOpen = !settingsOpen"
|
||||||
|
>
|
||||||
|
<Settings class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="h-4 w-px bg-gray-600 mx-1" />
|
||||||
|
|
||||||
|
<!-- Disconnect -->
|
||||||
|
<button
|
||||||
|
class="toolbar-btn toolbar-btn-danger"
|
||||||
|
title="Disconnect"
|
||||||
|
@click="disconnect"
|
||||||
|
>
|
||||||
|
<X class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<!-- Clipboard Dialog -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="clipboardOpen"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
|
||||||
|
@click.self="clipboardOpen = false"
|
||||||
|
>
|
||||||
|
<div class="bg-gray-800 border border-gray-700 rounded-xl shadow-2xl w-full max-w-md p-6">
|
||||||
|
<h2 class="text-gray-100 font-semibold text-lg mb-4 flex items-center gap-2">
|
||||||
|
<Clipboard class="w-5 h-5 text-wraith-400" />
|
||||||
|
Send to clipboard
|
||||||
|
</h2>
|
||||||
|
<p class="text-gray-400 text-sm mb-3">
|
||||||
|
Type or paste text here. It will be sent to the remote session clipboard.
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
v-model="clipboardText"
|
||||||
|
class="w-full bg-gray-900 border border-gray-600 rounded-lg text-gray-100 text-sm p-3 resize-none focus:outline-none focus:ring-2 focus:ring-wraith-500"
|
||||||
|
rows="5"
|
||||||
|
placeholder="Paste text to send..."
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
<div class="flex justify-end gap-3 mt-4">
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 rounded-lg bg-gray-700 hover:bg-gray-600 text-gray-300 text-sm transition-colors"
|
||||||
|
@click="clipboardOpen = false"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 rounded-lg bg-wraith-600 hover:bg-wraith-500 text-white text-sm font-medium transition-colors"
|
||||||
|
:disabled="!clipboardText"
|
||||||
|
@click="pasteClipboard"
|
||||||
|
>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<!-- Settings panel (minimal — expand in later tasks) -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="settingsOpen"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
|
||||||
|
@click.self="settingsOpen = false"
|
||||||
|
>
|
||||||
|
<div class="bg-gray-800 border border-gray-700 rounded-xl shadow-2xl w-full max-w-sm p-6">
|
||||||
|
<h2 class="text-gray-100 font-semibold text-lg mb-4 flex items-center gap-2">
|
||||||
|
<Settings class="w-5 h-5 text-wraith-400" />
|
||||||
|
RDP Settings
|
||||||
|
</h2>
|
||||||
|
<p class="text-gray-400 text-sm">
|
||||||
|
Advanced RDP settings (color depth, resize behavior) will be configurable here in a future release.
|
||||||
|
</p>
|
||||||
|
<div class="flex justify-end mt-6">
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 rounded-lg bg-gray-700 hover:bg-gray-600 text-gray-300 text-sm transition-colors"
|
||||||
|
@click="settingsOpen = false"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.rdp-toolbar-wrapper {
|
||||||
|
@apply absolute inset-0 pointer-events-none z-20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rdp-toolbar {
|
||||||
|
@apply absolute top-4 left-1/2 -translate-x-1/2
|
||||||
|
flex items-center gap-1 px-3 py-1.5
|
||||||
|
bg-gray-900/90 backdrop-blur-sm
|
||||||
|
border border-gray-700 rounded-full shadow-xl
|
||||||
|
pointer-events-auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn {
|
||||||
|
@apply p-1.5 rounded-full text-gray-400 hover:text-gray-100 hover:bg-gray-700
|
||||||
|
transition-colors duration-150;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn-danger {
|
||||||
|
@apply hover:text-red-400 hover:bg-red-900/30;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toolbar slide-down animation */
|
||||||
|
.toolbar-slide-enter-active,
|
||||||
|
.toolbar-slide-leave-active {
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
.toolbar-slide-enter-from,
|
||||||
|
.toolbar-slide-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-50%) translateY(-8px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
import { useSessionStore } from '~/stores/session.store'
|
import { useSessionStore } from '~/stores/session.store'
|
||||||
|
|
||||||
const sessions = useSessionStore()
|
const sessions = useSessionStore()
|
||||||
@ -8,6 +9,26 @@ const sessions = useSessionStore()
|
|||||||
function isRealSession(id: string) {
|
function isRealSession(id: string) {
|
||||||
return !id.startsWith('pending-')
|
return !id.startsWith('pending-')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-session refs to RdpCanvas instances (keyed by session.id)
|
||||||
|
// Used to forward toolbar actions (clipboard, disconnect) to the canvas
|
||||||
|
const rdpCanvasRefs = ref<Record<string, any>>({})
|
||||||
|
|
||||||
|
function setRdpRef(sessionId: string, el: any) {
|
||||||
|
if (el) {
|
||||||
|
rdpCanvasRefs.value[sessionId] = el
|
||||||
|
} else {
|
||||||
|
delete rdpCanvasRefs.value[sessionId]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRdpDisconnect(sessionId: string) {
|
||||||
|
rdpCanvasRefs.value[sessionId]?.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRdpClipboard(sessionId: string, text: string) {
|
||||||
|
rdpCanvasRefs.value[sessionId]?.sendClipboard(text)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -42,10 +63,20 @@ function isRealSession(id: string) {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- RDP session placeholder (Phase 3) -->
|
<!-- RDP session: Guacamole canvas + floating toolbar -->
|
||||||
<template v-else-if="session.protocol === 'rdp'">
|
<template v-else-if="session.protocol === 'rdp'">
|
||||||
<div class="flex-1 flex items-center justify-center text-gray-600">
|
<div class="flex-1 overflow-hidden relative">
|
||||||
RDP — Phase 3
|
<RdpCanvas
|
||||||
|
:ref="(el) => setRdpRef(session.id, el)"
|
||||||
|
:host-id="session.hostId"
|
||||||
|
:host-name="session.hostName"
|
||||||
|
:color="session.color"
|
||||||
|
/>
|
||||||
|
<RdpToolbar
|
||||||
|
:host-name="session.hostName"
|
||||||
|
@disconnect="handleRdpDisconnect(session.id)"
|
||||||
|
@send-clipboard="(text) => handleRdpClipboard(session.id, text)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
243
frontend/composables/useRdp.ts
Normal file
243
frontend/composables/useRdp.ts
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
import Guacamole from 'guacamole-common-js'
|
||||||
|
import { useAuthStore } from '~/stores/auth.store'
|
||||||
|
import { useSessionStore } from '~/stores/session.store'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom Guacamole Tunnel that speaks JSON over WebSocket.
|
||||||
|
*
|
||||||
|
* Why custom instead of Guacamole.WebSocketTunnel:
|
||||||
|
* - The standard WebSocketTunnel expects raw Guacamole protocol over WS.
|
||||||
|
* - Our /ws/rdp gateway wraps all messages in JSON envelopes (same pattern
|
||||||
|
* as /ws/terminal and /ws/sftp) for auth, connect handshake, and error
|
||||||
|
* signaling.
|
||||||
|
* - 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: '...' }
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
onGatewayError: ((message: string) => void) | null = null
|
||||||
|
|
||||||
|
constructor(wsUrl: string, connectMsg: object) {
|
||||||
|
super()
|
||||||
|
this.wsUrl = wsUrl
|
||||||
|
this.connectMsg = connectMsg
|
||||||
|
}
|
||||||
|
|
||||||
|
connect(_data?: string) {
|
||||||
|
this.ws = new WebSocket(this.wsUrl)
|
||||||
|
|
||||||
|
this.ws.onopen = () => {
|
||||||
|
// Step 1: send our JSON connect handshake
|
||||||
|
this.ws!.send(JSON.stringify(this.connectMsg))
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ws.onmessage = (event: MessageEvent) => {
|
||||||
|
const msg = JSON.parse(event.data as string)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
this.onGatewayError?.(msg.message as string)
|
||||||
|
this.onerror?.(new Guacamole.Status(Guacamole.Status.Code.SERVER_ERROR, msg.message))
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'disconnected':
|
||||||
|
this.onDisconnected?.(msg.reason as string)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ws.onclose = (event: CloseEvent) => {
|
||||||
|
if (event.code === 4001) {
|
||||||
|
this.onerror?.(new Guacamole.Status(Guacamole.Status.Code.CLIENT_FORBIDDEN, 'Unauthorized'))
|
||||||
|
}
|
||||||
|
this.onDisconnected?.('WebSocket closed')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ws.onerror = () => {
|
||||||
|
this.onerror?.(new Guacamole.Status(Guacamole.Status.Code.SERVER_ERROR, 'WebSocket error'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||||
|
this.ws.send(JSON.stringify({ type: 'guac', instruction: message }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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) {
|
||||||
|
const dotIdx = instruction.indexOf('.', pos)
|
||||||
|
if (dotIdx === -1) break
|
||||||
|
const len = parseInt(instruction.substring(pos, dotIdx), 10)
|
||||||
|
if (isNaN(len)) break
|
||||||
|
const value = instruction.substring(dotIdx + 1, dotIdx + 1 + len)
|
||||||
|
parts.push(value)
|
||||||
|
pos = dotIdx + 1 + len + 1 // skip comma separator
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length > 0) {
|
||||||
|
const opcode = parts[0]
|
||||||
|
const args = parts.slice(1)
|
||||||
|
this.oninstruction(opcode, args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Composable ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useRdp() {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const sessions = useSessionStore()
|
||||||
|
|
||||||
|
function connectRdp(
|
||||||
|
container: HTMLElement,
|
||||||
|
hostId: number,
|
||||||
|
hostName: string,
|
||||||
|
color: string | null,
|
||||||
|
options?: { width?: number; height?: number },
|
||||||
|
) {
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
|
||||||
|
const wsUrl = `${proto}://${location.host}/ws/rdp?token=${auth.token}`
|
||||||
|
|
||||||
|
const width = options?.width || container.clientWidth || 1920
|
||||||
|
const height = options?.height || container.clientHeight || 1080
|
||||||
|
|
||||||
|
const tunnel = new JsonWsTunnel(wsUrl, {
|
||||||
|
type: 'connect',
|
||||||
|
hostId,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
})
|
||||||
|
|
||||||
|
const client = new Guacamole.Client(tunnel)
|
||||||
|
|
||||||
|
// Session store ID — created optimistically, will be confirmed on 'connected'
|
||||||
|
const sessionId = `rdp-${hostId}-${Date.now()}`
|
||||||
|
|
||||||
|
// Wire tunnel callbacks
|
||||||
|
tunnel.onConnected = (_resolvedHostId: number, resolvedHostName: string) => {
|
||||||
|
sessions.addSession({
|
||||||
|
id: sessionId,
|
||||||
|
hostId,
|
||||||
|
hostName: resolvedHostName || hostName,
|
||||||
|
protocol: 'rdp',
|
||||||
|
color,
|
||||||
|
active: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
tunnel.onDisconnected = (_reason: string) => {
|
||||||
|
sessions.removeSession(sessionId)
|
||||||
|
client.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
tunnel.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 — attach to document so key events fire even when
|
||||||
|
// focus is on other elements (toolbar buttons, etc.)
|
||||||
|
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)
|
||||||
|
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 }
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user