"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 FormState = { proxyAddress: string; host: string; port: string; password: string; authToken: string; }; export default function VncClient() { const [form, setForm] = useState({ proxyAddress: "ws://localhost:7171/jet/vnc", host: "", port: "5900", password: "", authToken: "abc123" }); 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 (!form.host) { toast({ variant: "destructive", title: "Missing host", description: "Enter the VNC server hostname or IP" }); 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 base = form.proxyAddress.replace(/\/$/, ""); const params = new URLSearchParams({ authToken: form.authToken, host: form.host, port: form.port }); const wsUrl = `${base}?${params.toString()}`; toast({ title: "Connecting…", description: wsUrl }); // 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", () => { toast({ title: "Connected" }); setConnected(true); }); rfb.addEventListener( "disconnect", (e: { detail: { clean: boolean } }) => { rfbRef.current = null; setConnected(false); toast({ title: e.detail.clean ? "Disconnected" : "Connection lost", variant: e.detail.clean ? "default" : "destructive" }); } ); 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; }; return (
{!connected && (

VNC Test Connection

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