"use client"; import { useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { toast } from "@app/hooks/useToast"; type Target = { ip: string; port: number; authToken: string; }; type FormState = { password: string; }; export default function VncClient({ target, error }: { target: Target | null; error: string | null; }) { const [form, setForm] = useState({ password: "" }); const [connected, setConnected] = useState(false); // eslint-disable-next-line @typescript-eslint/no-explicit-any const rfbRef = useRef(null); const screenRef = useRef(null); const update = (key: K, value: FormState[K]) => { setForm((prev) => ({ ...prev, [key]: value })); }; // Disconnect and clean up the RFB instance. const disconnect = () => { if (rfbRef.current) { rfbRef.current.disconnect(); rfbRef.current = null; } setConnected(false); }; // Clean up on unmount. useEffect(() => { return () => disconnect(); }, []); // eslint-disable-line react-hooks/exhaustive-deps const connect = async () => { if (!target) { toast({ variant: "destructive", title: "No target", description: "No resource target is available" }); return; } if (!screenRef.current) return; // Disconnect any existing session first. disconnect(); // noVNC has no ESM default export — import the module dynamically to // keep it out of the server bundle, then grab the default export. let RFB: new ( target: HTMLElement, url: string, options?: Record ) => unknown; try { // @ts-expect-error — @novnc/novnc ships plain JS with no bundled types const mod = await import("@novnc/novnc"); RFB = mod.default ?? mod; } catch (err) { toast({ variant: "destructive", title: "Failed to load noVNC", description: `${err}` }); return; } // Build the proxy WebSocket URL: // ws://?authToken=&host=&port= const proxyAddress = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/vnc`; const base = proxyAddress.replace(/\/$/, ""); const params = new URLSearchParams({ host: target.ip, port: String(target.port), authToken: target.authToken }); const wsUrl = `${base}?${params.toString()}`; // Clear the container so noVNC gets a clean mount point. screenRef.current.innerHTML = ""; const options: Record = {}; if (form.password) { options.credentials = { password: form.password }; } // eslint-disable-next-line @typescript-eslint/no-explicit-any const rfb: any = new RFB(screenRef.current, wsUrl, options); rfb.scaleViewport = true; rfb.resizeSession = true; rfb.addEventListener("connect", () => { setConnected(true); }); rfb.addEventListener( "disconnect", (e: { detail: { clean: boolean } }) => { rfbRef.current = null; setConnected(false); } ); rfb.addEventListener( "securityfailure", (e: { detail: { status: number; reason?: string } }) => { toast({ variant: "destructive", title: "Authentication failed", description: e.detail.reason ?? `Status ${e.detail.status}` }); } ); rfbRef.current = rfb; }; if (error) { return (

{error}

); } return (
{!connected && (

VNC Test Connection

update("password", e.target.value) } />
)}
{/* noVNC mounts a inside this div */}
); } function Field({ label, id, children }: { label: string; id: string; children: React.ReactNode; }) { return (
{children}
); }