187 lines
6.9 KiB
TypeScript
187 lines
6.9 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'
|
|
|
|
type SessionStatus = 'connecting' | 'connected' | 'error'
|
|
|
|
interface Session {
|
|
sessionId: string
|
|
hostId: number
|
|
name: string
|
|
status: SessionStatus
|
|
errorMessage: string
|
|
}
|
|
|
|
interface SessionHandle {
|
|
client: any
|
|
container: HTMLDivElement
|
|
}
|
|
|
|
export default function RemoteDesktop() {
|
|
const [hosts, setHosts] = useState<Integration[]>([])
|
|
const [sessions, setSessions] = useState<Session[]>([])
|
|
const [activeSessionId, setActiveSessionId] = useState<string | null>(null)
|
|
const displayHostRef = useRef<HTMLDivElement>(null)
|
|
const handlesRef = useRef<Map<string, SessionHandle>>(new Map())
|
|
|
|
useEffect(() => {
|
|
api.listIntegrations().then(({ integrations }) => {
|
|
setHosts(integrations.filter((i) => i.type === 'remote_desktop'))
|
|
})
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
handlesRef.current.forEach(({ client }) => client.disconnect())
|
|
}
|
|
}, [])
|
|
|
|
function patchSession(sessionId: string, patch: Partial<Session>) {
|
|
setSessions((prev) => prev.map((s) => (s.sessionId === sessionId ? { ...s, ...patch } : s)))
|
|
}
|
|
|
|
function openSession(host: Integration) {
|
|
const sessionId = crypto.randomUUID()
|
|
const session: Session = { sessionId, hostId: host.id, name: host.name, status: 'connecting', errorMessage: '' }
|
|
setSessions((prev) => [...prev, session])
|
|
setActiveSessionId(sessionId)
|
|
|
|
const container = document.createElement('div')
|
|
container.className = 'h-full w-full overflow-auto'
|
|
|
|
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)
|
|
handlesRef.current.set(sessionId, { client, container })
|
|
|
|
client.onerror = (err: { message?: string }) => {
|
|
patchSession(sessionId, { status: 'error', errorMessage: err?.message ?? 'Connection failed' })
|
|
}
|
|
client.onstatechange = (state: number) => {
|
|
if (state === 3) patchSession(sessionId, { status: 'connected' })
|
|
}
|
|
|
|
container.appendChild(client.getDisplay().getElement())
|
|
client.connect(`token=${encodeURIComponent(token ?? '')}&integrationId=${host.id}`)
|
|
}
|
|
|
|
function closeSession(sessionId: string) {
|
|
const handle = handlesRef.current.get(sessionId)
|
|
handle?.client.disconnect()
|
|
handle?.container.remove()
|
|
handlesRef.current.delete(sessionId)
|
|
setSessions((prev) => {
|
|
const next = prev.filter((s) => s.sessionId !== sessionId)
|
|
if (activeSessionId === sessionId) {
|
|
setActiveSessionId(next.length > 0 ? next[next.length - 1].sessionId : null)
|
|
}
|
|
return next
|
|
})
|
|
}
|
|
|
|
// Show only the active session's display element; keep the rest mounted off-DOM so
|
|
// background sessions stay connected while their tab isn't focused.
|
|
useEffect(() => {
|
|
const host = displayHostRef.current
|
|
if (!host) return
|
|
host.innerHTML = ''
|
|
const active = activeSessionId ? handlesRef.current.get(activeSessionId) : null
|
|
if (active) host.appendChild(active.container)
|
|
}, [activeSessionId])
|
|
|
|
const activeSession = sessions.find((s) => s.sessionId === activeSessionId)
|
|
|
|
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={() => openSession(h)}
|
|
className="mb-1 w-full rounded-md px-2 py-1.5 text-left text-sm transition hover:bg-white/10"
|
|
style={{ color: '#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 gap-1 overflow-x-auto border-b border-white/10 px-2 py-1.5">
|
|
{sessions.length === 0 && (
|
|
<p className="px-1 text-sm" style={{ color: TEXT_SECONDARY }}>
|
|
Select a remote desktop to connect
|
|
</p>
|
|
)}
|
|
{sessions.map((s) => (
|
|
<div
|
|
key={s.sessionId}
|
|
onClick={() => setActiveSessionId(s.sessionId)}
|
|
className="flex shrink-0 cursor-pointer items-center gap-2 rounded-md px-2 py-1 text-sm"
|
|
style={{
|
|
background: activeSessionId === s.sessionId ? 'rgba(200,164,52,0.15)' : 'transparent',
|
|
color: activeSessionId === s.sessionId ? '#C8A434' : '#E8E6E0',
|
|
}}
|
|
>
|
|
<span
|
|
className="h-1.5 w-1.5 rounded-full"
|
|
style={{
|
|
background: s.status === 'connected' ? '#4ADE80' : s.status === 'error' ? '#F87171' : '#FBBF24',
|
|
}}
|
|
/>
|
|
{s.name}
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
closeSession(s.sessionId)
|
|
}}
|
|
className="ml-1 rounded px-1 text-xs hover:bg-white/10"
|
|
style={{ color: TEXT_SECONDARY }}
|
|
title="Disconnect"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{activeSession && (
|
|
<div className="flex items-center justify-between border-b border-white/10 px-3 py-2">
|
|
<p className="text-sm" style={{ color: TEXT_SECONDARY }}>
|
|
{activeSession.name}
|
|
</p>
|
|
<div className="flex items-center gap-3">
|
|
<p className="text-xs" style={{ color: TEXT_SECONDARY }}>
|
|
{activeSession.status === 'connecting' && 'Connecting…'}
|
|
{activeSession.status === 'connected' && 'Connected'}
|
|
{activeSession.status === 'error' && `Error: ${activeSession.errorMessage}`}
|
|
</p>
|
|
<button
|
|
onClick={() => closeSession(activeSession.sessionId)}
|
|
className="rounded-md border border-white/10 px-2 py-1 text-xs transition hover:bg-white/10"
|
|
style={{ color: '#E8E6E0' }}
|
|
>
|
|
Disconnect
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div ref={displayHostRef} className="flex-1 overflow-auto" />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|