"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 type { UserInteraction, IronError, FileTransferProvider } from "@devolutions/iron-remote-desktop/dist"; import type { RdpFileTransferProvider, FileInfo } from "@devolutions/iron-remote-desktop-rdp/dist"; 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 { useTranslations } from "next-intl"; declare module "react" { namespace JSX { interface IntrinsicElements { "iron-remote-desktop": React.DetailedHTMLProps< React.HTMLAttributes & { scale?: string; verbose?: string; flexcenter?: string; module?: unknown; }, HTMLElement >; } } } type RdpCredentialsForm = { username: string; password: string; domain: string; kdcProxyUrl: string; pcb: string; enableClipboard: boolean; }; function loadStoredCredentials(key: string): RdpCredentialsForm { try { const saved = localStorage.getItem(key); if (saved) return JSON.parse(saved) as RdpCredentialsForm; } catch { // ignore } return { username: "", password: "", domain: "", kdcProxyUrl: "", pcb: "", enableClipboard: true }; } const isIronError = (error: unknown): error is IronError => { return ( typeof error === "object" && error !== null && typeof (error as IronError).backtrace === "function" && typeof (error as IronError).kind === "function" ); }; export default function RdpClient({ target, error, primaryColor }: { target: GetBrowserTargetResponse | null; error: string | null; primaryColor?: string | null; }) { const t = useTranslations(); const STORAGE_KEY = "pangolin_rdp_credentials"; const resourceName = target?.name?.trim() || null; const formSchema = z.object({ username: z.string().min(1, { message: t("usernameRequired") }), password: z.string().min(1, { message: t("passwordRequired") }), domain: z.string(), kdcProxyUrl: z.string(), pcb: z.string(), enableClipboard: z.boolean() }); const form = useForm({ resolver: zodResolver(formSchema), defaultValues: loadStoredCredentials(STORAGE_KEY) }); const [showLogin, setShowLogin] = useState(true); const [moduleReady, setModuleReady] = useState(false); const [connecting, setConnecting] = useState(false); const [submitError, setSubmitError] = useState(null); const [unicodeMode, setUnicodeMode] = useState(false); const [cursorOverrideActive, setCursorOverrideActive] = useState(false); const userInteractionRef = useRef(null); const backendRef = useRef(null); // Holds the RdpFileTransferProvider constructor so we can create a fresh // instance per session (avoids stale upload state across reconnects). const fileTransferClassRef = useRef( null ); // Active session's provider instance; replaced on each connect. const fileTransferRef = useRef(null); const extensionsRef = useRef<{ displayControl: (enable: boolean) => unknown; preConnectionBlob: (pcb: string) => unknown; kdcProxyUrl: (url: string) => unknown; } | null>(null); // Load the iron-remote-desktop modules client-side and register the // `` custom element. useEffect(() => { let cancelled = false; (async () => { const [coreMod, rdpMod] = await Promise.all([ import("@devolutions/iron-remote-desktop/dist"), import("@devolutions/iron-remote-desktop-rdp/dist") ]); if (cancelled) return; await rdpMod.init("INFO"); backendRef.current = rdpMod.Backend; extensionsRef.current = { displayControl: rdpMod.displayControl, preConnectionBlob: rdpMod.preConnectionBlob, kdcProxyUrl: rdpMod.kdcProxyUrl }; // Store the class; a fresh instance is created per session. fileTransferClassRef.current = rdpMod.RdpFileTransferProvider as unknown as typeof RdpFileTransferProvider; // Importing the package registers the custom element as a side // effect. Touch the default export to avoid tree-shaking. void coreMod; setModuleReady(true); })().catch((err) => { console.error("Failed to load iron-remote-desktop modules", err); toast({ variant: "destructive", title: t("rdpFailedToLoadModule"), description: `${err}` }); }); return () => { cancelled = true; }; }, []); // Attach the "ready" listener synchronously the moment the custom // element mounts. The custom element dispatches `ready` from its own // `onMount`, so a deferred useEffect can race and miss it. const remoteElementRef = (el: HTMLElement | null) => { if (!el) return; const onReady = (e: Event) => { const event = e as CustomEvent; userInteractionRef.current = event.detail.irgUserInteraction; }; el.addEventListener("ready", onReady); }; const startSession = async (values: RdpCredentialsForm) => { setConnecting(true); const userInteraction = userInteractionRef.current; const exts = extensionsRef.current; if (!userInteraction || !exts) { setConnecting(false); setSubmitError(t("rdpModuleInitializing")); return; } userInteraction.setEnableClipboard(values.enableClipboard); // Dispose any previous session's provider and create a fresh one so // there is no stale upload state from a prior connection. fileTransferRef.current?.dispose(); const ProviderClass = fileTransferClassRef.current; const fileTransfer = ProviderClass ? new ProviderClass() : null; fileTransferRef.current = fileTransfer; if (fileTransfer) { // Auto-download files when the remote copies them to clipboard. fileTransfer.on("files-available", (files: FileInfo[]) => { const downloadable = files.filter((f) => !f.isDirectory); if (downloadable.length === 0) return; toast({ title: t("rdpDownloadingFiles", { count: downloadable.length }) }); for (let i = 0; i < files.length; i++) { const file = files[i]; if (file.isDirectory) continue; const { completion } = fileTransfer.downloadFile(file, i); completion .then((blob) => { const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = file.name; a.click(); URL.revokeObjectURL(url); }) .catch((err) => { toast({ variant: "destructive", title: t("rdpDownloadFailed", { fileName: file.name }), description: `${err}` }); }); } }); // Notify when individual uploads complete (remote pasted a file). fileTransfer.on("upload-complete", (file: File) => { toast({ title: t("rdpUploaded", { fileName: file.name }) }); }); // Register with the web component so CLIPRDR extensions are // wired up before connect() builds the session. userInteraction.enableFileTransfer( fileTransfer as unknown as FileTransferProvider ); } if (!target) { setConnecting(false); setSubmitError(t("rdpNoConnectionTarget")); return; } const destination = `${target.ip}:${target.port}`; const builder = userInteraction .configBuilder() .withUsername(values.username) .withPassword(values.password) .withDestination(destination) .withProxyAddress( `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/rdp` ) .withServerDomain(values.domain) .withAuthToken(target.authToken) .withDesktopSize({ width: window.innerWidth, height: window.innerHeight }) .withExtension(exts.displayControl(true)); if (values.pcb !== "") { builder.withExtension(exts.preConnectionBlob(values.pcb)); } if (values.kdcProxyUrl !== "") { builder.withExtension(exts.kdcProxyUrl(values.kdcProxyUrl)); } try { const sessionInfo = await userInteraction.connect(builder.build()); try { localStorage.setItem(STORAGE_KEY, JSON.stringify(values)); } catch { // ignore } setConnecting(false); setShowLogin(false); userInteraction.setVisibility(true); const termInfo = await sessionInfo.run(); fileTransferRef.current?.dispose(); fileTransferRef.current = null; setShowLogin(true); } catch (err) { setConnecting(false); setShowLogin(true); if (isIronError(err)) { setSubmitError(err.backtrace()); } else { setSubmitError(`${err}`); } } }; const onSubmit = (values: RdpCredentialsForm) => { setSubmitError(null); startSession(values); }; const ui = () => userInteractionRef.current; const toggleCursorKind = () => { const u = ui(); if (!u) return; if (cursorOverrideActive) { u.setCursorStyleOverride(null); } else { u.setCursorStyleOverride('url("crosshair.png") 7 7, default'); } setCursorOverrideActive((v) => !v); }; if (error) { return ( {t("rdpTitle")} {error} ); } return ( <> {showLogin && ( {resourceName ? `${t("rdpSignInTitle")} - ${resourceName}` : t("rdpSignInTitle")} {resourceName ? `${t("rdpSignInDescription")} (${resourceName})` : t("rdpSignInDescription")}
( {t("domain")} )} /> ( {t("username")} )} /> ( {t("password")} )} /> {submitError && ( {submitError} )}
)}
{/* */}
{moduleReady && ( )}
); }