diff --git a/next.config.ts b/next.config.ts index 630a3416f..b3d7a7f45 100644 --- a/next.config.ts +++ b/next.config.ts @@ -5,6 +5,7 @@ const withNextIntl = createNextIntlPlugin(); const nextConfig: NextConfig = { reactStrictMode: false, + transpilePackages: ["@novnc/novnc"], eslint: { ignoreDuringBuilds: true }, diff --git a/package-lock.json b/package-lock.json index f9678e7b8..8d6fbdffc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@hookform/resolvers": "5.2.2", "@monaco-editor/react": "4.7.0", "@node-rs/argon2": "2.0.2", + "@novnc/novnc": "^1.7.0", "@oslojs/crypto": "1.0.1", "@oslojs/encoding": "1.1.0", "@radix-ui/react-avatar": "1.1.11", @@ -3645,6 +3646,12 @@ "node": ">=12.4.0" } }, + "node_modules/@novnc/novnc": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@novnc/novnc/-/novnc-1.7.0.tgz", + "integrity": "sha512-ucEJOx4T2avIRCleodk7YobZj5O2Ga2AeLfQ69A/yjG9HHba2+PDgwSkN3FttrmG+70ZGx21sElNFouK13RzyA==", + "license": "MPL-2.0" + }, "node_modules/@oslojs/asn1": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@oslojs/asn1/-/asn1-1.0.0.tgz", diff --git a/package.json b/package.json index 33e54c3d0..c2e9d173d 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@hookform/resolvers": "5.2.2", "@monaco-editor/react": "4.7.0", "@node-rs/argon2": "2.0.2", + "@novnc/novnc": "^1.7.0", "@oslojs/crypto": "1.0.1", "@oslojs/encoding": "1.1.0", "@radix-ui/react-avatar": "1.1.11", diff --git a/src/app/vnc/VncClient.tsx b/src/app/vnc/VncClient.tsx new file mode 100644 index 000000000..4535546e4 --- /dev/null +++ b/src/app/vnc/VncClient.tsx @@ -0,0 +1,271 @@ +"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} +
+ ); +} diff --git a/src/app/vnc/page.tsx b/src/app/vnc/page.tsx new file mode 100644 index 000000000..0a9b4b4c3 --- /dev/null +++ b/src/app/vnc/page.tsx @@ -0,0 +1,11 @@ +import VncClient from "./VncClient"; + +export const dynamic = "force-dynamic"; + +export const metadata = { + title: "VNC Test" +}; + +export default function VncPage() { + return ; +}