Files
pangolin/src/app/vnc/VncClient.tsx
2026-06-09 21:36:54 -07:00

357 lines
12 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Button } from "@app/components/ui/button";
import { Input } from "@app/components/ui/input";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { toast } from "@app/hooks/useToast";
import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from "@app/components/ui/card";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import BrandedAuthSurface from "@app/components/BrandedAuthSurface";
import PoweredByPangolin from "@app/components/PoweredByPangolin";
import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices";
import CollapsibleSessionToolbar from "@app/components/CollapsibleSessionToolbar";
import { useTranslations } from "next-intl";
import {
loadEncryptedLocalStorage,
saveEncryptedLocalStorage
} from "@app/lib/secureLocalStorage";
type VncCredentialsForm = {
password: string;
};
const DEFAULT_VNC_CREDENTIALS: VncCredentialsForm = {
password: ""
};
export default function VncClient({
target,
error,
primaryColor
}: {
target: GetBrowserTargetResponse | null;
error: string | null;
primaryColor?: string | null;
}) {
const t = useTranslations();
const STORAGE_KEY = "pangolin_vnc_credentials";
const resourceName = target?.name?.trim() || null;
const formSchema = z.object({
password: z.string()
});
const form = useForm<VncCredentialsForm>({
resolver: zodResolver(formSchema),
defaultValues: DEFAULT_VNC_CREDENTIALS
});
useEffect(() => {
let cancelled = false;
void loadEncryptedLocalStorage<VncCredentialsForm>(
STORAGE_KEY,
target?.authToken
).then((saved) => {
if (cancelled || !saved) return;
form.reset({ ...DEFAULT_VNC_CREDENTIALS, ...saved });
});
return () => {
cancelled = true;
};
}, [form, target?.authToken]);
const [connected, setConnected] = useState(false);
const [connecting, setConnecting] = useState(false);
const [connectError, setConnectError] = useState<string | null>(null);
const rfbRef = useRef<any>(null);
const screenRef = useRef<HTMLDivElement>(null);
const disconnect = () => {
if (rfbRef.current) {
rfbRef.current.disconnect();
rfbRef.current = null;
}
setConnecting(false);
setConnected(false);
};
useEffect(() => {
return () => disconnect();
}, []);
const connect = async (values: VncCredentialsForm) => {
setConnecting(true);
if (!target) {
setConnectError(t("vncNoResourceTarget"));
setConnecting(false);
return;
}
if (!screenRef.current) {
setConnectError(t("sshErrorConnectionClosed"));
setConnecting(false);
return;
}
disconnect();
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
});
try {
const checkParams = new URLSearchParams(params);
checkParams.set("checkOnly", "1");
const response = await fetch(`${base}?${checkParams.toString()}`);
if (!response.ok) {
const detail = (await response.text()).trim();
setConnectError(detail || t("sshErrorConnectionClosed"));
setConnecting(false);
return;
}
} catch {
setConnectError(t("sshErrorWebSocket"));
setConnecting(false);
return;
}
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) {
setConnecting(false);
setConnectError(t("sshErrorWebSocket"));
toast({
variant: "destructive",
title: t("vncFailedToLoadNovnc"),
description: `${err}`
});
return;
}
const wsUrl = `${base}?${params.toString()}`;
screenRef.current.innerHTML = "";
const options: Record<string, unknown> = {};
if (values.password) {
options.credentials = { password: values.password };
}
let rfb: any;
try {
rfb = new RFB(screenRef.current, wsUrl, options);
} catch {
setConnecting(false);
setConnectError(t("sshErrorWebSocket"));
return;
}
let authConfirmed = false;
rfb.scaleViewport = true;
rfb.resizeSession = true;
rfb.addEventListener("connect", () => {
void saveEncryptedLocalStorage(
STORAGE_KEY,
values,
target.authToken
);
authConfirmed = true;
setConnecting(false);
setConnected(true);
});
rfb.addEventListener(
"disconnect",
(e: { detail: { clean: boolean } }) => {
rfbRef.current = null;
setConnecting(false);
setConnected(false);
if (!authConfirmed && !e.detail.clean) {
setConnectError(t("sshErrorConnectionClosed"));
}
}
);
rfb.addEventListener(
"securityfailure",
(e: { detail: { status: number; reason?: string } }) => {
disconnect();
setConnectError(
e.detail.reason ??
t("vncAuthFailedStatus", {
status: e.detail.status
})
);
}
);
rfbRef.current = rfb;
};
const onSubmit = (values: VncCredentialsForm) => {
setConnectError(null);
connect(values);
};
if (error) {
return (
<BrandedAuthSurface primaryColor={primaryColor}>
<PoweredByPangolin />
<Card className="w-full">
<CardHeader>
<CardTitle>{t("vncTitle")}</CardTitle>
</CardHeader>
<CardContent>
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
</CardContent>
</Card>
</BrandedAuthSurface>
);
}
return (
<>
{!connected && (
<BrandedAuthSurface primaryColor={primaryColor}>
<PoweredByPangolin />
<Card className="w-full">
<CardHeader>
<CardTitle>{t("vncTitle")}</CardTitle>
<CardDescription>
{resourceName
? `${t("vncSignInDescription")} (${resourceName})`
: t("vncSignInDescription")}
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("vncPasswordOptional")}
</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{connectError && (
<Alert variant="destructive">
<AlertDescription>
{connectError}
</AlertDescription>
</Alert>
)}
<Button
type="submit"
className="w-full"
loading={connecting}
disabled={connecting}
>
{t("browserGatewayConnect")}
</Button>
</form>
</Form>
</CardContent>
</Card>
<AuthPageFooterNotices />
</BrandedAuthSurface>
)}
<div
className="fixed inset-0 z-50 flex flex-col bg-neutral-900"
style={{ display: connected ? "flex" : "none" }}
>
<CollapsibleSessionToolbar>
<Button
size="sm"
variant="destructive"
onClick={disconnect}
>
{t("sshTerminate")}
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => {
if (rfbRef.current) {
rfbRef.current.sendCtrlAltDel();
}
}}
>
{t("browserGatewayCtrlAltDel")}
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => {
navigator.clipboard
?.readText()
.then((text) => {
rfbRef.current?.clipboardPasteFrom(text);
})
.catch(() => {});
}}
>
{t("vncPasteClipboard")}
</Button>
</CollapsibleSessionToolbar>
<div
ref={screenRef}
className="flex-1 overflow-hidden"
style={{ background: "#000" }}
/>
</div>
</>
);
}