mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-15 04:57:24 +00:00
Add basic vnc test
This commit is contained in:
@@ -5,6 +5,7 @@ const withNextIntl = createNextIntlPlugin();
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
reactStrictMode: false,
|
||||
transpilePackages: ["@novnc/novnc"],
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true
|
||||
},
|
||||
|
||||
7
package-lock.json
generated
7
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
271
src/app/vnc/VncClient.tsx
Normal file
271
src/app/vnc/VncClient.tsx
Normal file
@@ -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<FormState>({
|
||||
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<any>(null);
|
||||
const screenRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const update = <K extends keyof FormState>(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<string, unknown>
|
||||
) => 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://<proxyAddress>?authToken=<token>&host=<host>&port=<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<string, unknown> = {};
|
||||
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 (
|
||||
<div className="min-h-screen bg-background">
|
||||
{!connected && (
|
||||
<div className="mx-auto max-w-2xl p-6">
|
||||
<h1 className="mb-4 text-2xl font-semibold">
|
||||
VNC Test Connection
|
||||
</h1>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Field label="VNC Host" id="host">
|
||||
<Input
|
||||
id="host"
|
||||
placeholder="192.168.1.100"
|
||||
value={form.host}
|
||||
onChange={(e) => update("host", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="VNC Port" id="port">
|
||||
<Input
|
||||
id="port"
|
||||
type="number"
|
||||
value={form.port}
|
||||
onChange={(e) => update("port", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Password (optional)" id="password">
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) =>
|
||||
update("password", e.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Proxy Address" id="proxyAddress">
|
||||
<Input
|
||||
id="proxyAddress"
|
||||
value={form.proxyAddress}
|
||||
onChange={(e) =>
|
||||
update("proxyAddress", e.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Auth Token" id="authToken">
|
||||
<Input
|
||||
id="authToken"
|
||||
value={form.authToken}
|
||||
onChange={(e) =>
|
||||
update("authToken", e.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Button onClick={connect} className="w-full">
|
||||
Connect
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="flex h-screen flex-col bg-neutral-900"
|
||||
style={{ display: connected ? "flex" : "none" }}
|
||||
>
|
||||
<div className="flex items-center gap-2 bg-black p-2 text-white">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
if (rfbRef.current) {
|
||||
rfbRef.current.sendCtrlAltDel();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Ctrl+Alt+Del
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
navigator.clipboard
|
||||
?.readText()
|
||||
.then((text) => {
|
||||
rfbRef.current?.clipboardPasteFrom(text);
|
||||
})
|
||||
.catch(() => {});
|
||||
}}
|
||||
>
|
||||
Paste clipboard
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={disconnect}
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* noVNC mounts a <canvas> inside this div */}
|
||||
<div
|
||||
ref={screenRef}
|
||||
className="flex-1 overflow-hidden"
|
||||
style={{ background: "#000" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
id,
|
||||
children
|
||||
}: {
|
||||
label: string;
|
||||
id: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
src/app/vnc/page.tsx
Normal file
11
src/app/vnc/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import VncClient from "./VncClient";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata = {
|
||||
title: "VNC Test"
|
||||
};
|
||||
|
||||
export default function VncPage() {
|
||||
return <VncClient />;
|
||||
}
|
||||
Reference in New Issue
Block a user