dev_arc_aws/src/pages/RemoteDesktop.tsx
Claude c37ad3d0d4
Phase 5: RDP/VNC/Telnet remote desktop via guacamole-lite + guacd
Adds a remote_desktop integration type and a /api/guacamole websocket
route that drives guacamole-lite's ClientConnection directly (bypassing
its Server class, which would otherwise attach an unfiltered upgrade
listener that conflicts with the existing @fastify/websocket routes).
The frontend RemoteDesktop page renders the Guacamole protocol stream
via guacamole-common-js. Verified end-to-end against a real guacd and
VNC server, including in an actual browser session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF
2026-06-19 15:25:10 +00:00

103 lines
3.6 KiB
TypeScript

import { useEffect, useRef, useState } from 'react'
import Guacamole from 'guacamole-common-js'
import { api, getToken, type Integration } from '../lib/api'
const TEXT_SECONDARY = '#7A7D85'
export default function RemoteDesktop() {
const [hosts, setHosts] = useState<Integration[]>([])
const [hostId, setHostId] = useState<number | null>(null)
const [status, setStatus] = useState<'idle' | 'connecting' | 'connected' | 'error'>('idle')
const [errorMessage, setErrorMessage] = useState('')
const displayRef = useRef<HTMLDivElement>(null)
const clientRef = useRef<any>(null)
useEffect(() => {
api.listIntegrations().then(({ integrations }) => {
setHosts(integrations.filter((i) => i.type === 'remote_desktop'))
})
}, [])
useEffect(() => {
return () => {
clientRef.current?.disconnect()
}
}, [])
function connect(id: number) {
clientRef.current?.disconnect()
if (displayRef.current) displayRef.current.innerHTML = ''
setStatus('connecting')
setErrorMessage('')
const token = getToken()
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
// Guacamole.WebSocketTunnel appends its own "?<data>" query string on connect(),
// so the tunnel URL itself must not already contain one.
const tunnel = new Guacamole.WebSocketTunnel(`${proto}://${window.location.host}/api/guacamole`)
const client = new Guacamole.Client(tunnel)
clientRef.current = client
client.onerror = (err: { message?: string }) => {
setStatus('error')
setErrorMessage(err?.message ?? 'Connection failed')
}
client.onstatechange = (state: number) => {
if (state === 3) setStatus('connected')
}
const display = client.getDisplay().getElement()
displayRef.current?.appendChild(display)
client.connect(`token=${encodeURIComponent(token ?? '')}&integrationId=${id}`)
}
function handleSelect(id: number) {
setHostId(id)
connect(id)
}
const host = hosts.find((h) => h.id === hostId)
return (
<div className="flex h-full gap-4">
<div className="w-56 shrink-0 overflow-y-auto rounded-lg border border-white/10 bg-white/5 p-3">
<p className="mb-2 text-xs font-medium uppercase tracking-wide" style={{ color: TEXT_SECONDARY }}>
Remote Desktops
</p>
{hosts.length === 0 && (
<p className="text-xs" style={{ color: TEXT_SECONDARY }}>
No remote desktop integrations configured. Add one in Settings Integrations.
</p>
)}
{hosts.map((h) => (
<button
key={h.id}
onClick={() => handleSelect(h.id)}
className="mb-1 w-full rounded-md px-2 py-1.5 text-left text-sm transition"
style={{
background: hostId === h.id ? 'rgba(200,164,52,0.15)' : 'transparent',
color: hostId === h.id ? '#C8A434' : '#E8E6E0',
}}
>
{h.name}
</button>
))}
</div>
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border border-white/10 bg-black">
<div className="flex items-center justify-between border-b border-white/10 px-3 py-2">
<p className="text-sm" style={{ color: TEXT_SECONDARY }}>
{host ? host.name : 'Select a remote desktop'}
</p>
<p className="text-xs" style={{ color: TEXT_SECONDARY }}>
{status === 'connecting' && 'Connecting…'}
{status === 'connected' && 'Connected'}
{status === 'error' && `Error: ${errorMessage}`}
</p>
</div>
<div ref={displayRef} className="flex-1 overflow-auto" />
</div>
</div>
)
}