"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"; import type { UserInteraction, IronError, FileTransferProvider } from "@devolutions/iron-remote-desktop/dist"; import type { RdpFileTransferProvider, FileInfo } from "@devolutions/iron-remote-desktop-rdp/dist"; declare module "react" { namespace JSX { interface IntrinsicElements { "iron-remote-desktop": React.DetailedHTMLProps< React.HTMLAttributes & { scale?: string; verbose?: string; flexcenter?: string; module?: unknown; }, HTMLElement >; } } } type Target = { ip: string; port: number; authToken: string; }; type FormState = { username: string; password: string; domain: string; kdcProxyUrl: string; pcb: string; enableClipboard: boolean; }; 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 }: { target: Target | null; error: string | null; }) { const STORAGE_KEY = "pangolin_rdp_credentials"; const [form, setForm] = useState(() => { try { const saved = localStorage.getItem(STORAGE_KEY); if (saved) return JSON.parse(saved) as FormState; } catch { // ignore } return { username: "", password: "", domain: "", kdcProxyUrl: "", pcb: "", enableClipboard: true }; }); const [showLogin, setShowLogin] = useState(true); const [moduleReady, setModuleReady] = useState(false); const [connecting, setConnecting] = useState(false); 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: "Failed to load RDP module", 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 update = (key: K, value: FormState[K]) => { setForm((prev) => ({ ...prev, [key]: value })); }; const startSession = async () => { setConnecting(true); const userInteraction = userInteractionRef.current; const exts = extensionsRef.current; if (!userInteraction || !exts) { setConnecting(false); toast({ variant: "destructive", title: "Not ready", description: "RDP module is still initializing" }); return; } userInteraction.setEnableClipboard(form.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: `Downloading ${downloadable.length} file(s) from remote…` }); 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: `Download failed: ${file.name}`, description: `${err}` }); }); } }); // Notify when individual uploads complete (remote pasted a file). fileTransfer.on("upload-complete", (file: File) => { toast({ title: `Uploaded: ${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); toast({ variant: "destructive", title: "No target", description: "No connection target available" }); return; } const destination = `${target.ip}:${target.port}`; const builder = userInteraction .configBuilder() .withUsername(form.username) .withPassword(form.password) .withDestination(destination) .withProxyAddress( `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/rdp` ) .withServerDomain(form.domain) .withAuthToken(target.authToken) .withDesktopSize({ width: window.innerWidth, height: window.innerHeight }) .withExtension(exts.displayControl(true)); if (form.pcb !== "") { builder.withExtension(exts.preConnectionBlob(form.pcb)); } if (form.kdcProxyUrl !== "") { builder.withExtension(exts.kdcProxyUrl(form.kdcProxyUrl)); } try { const sessionInfo = await userInteraction.connect(builder.build()); try { localStorage.setItem(STORAGE_KEY, JSON.stringify(form)); } 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)) { toast({ variant: "destructive", title: "Connection failed", description: err.backtrace() }); } else { toast({ variant: "destructive", title: "Connection failed", description: `${err}` }); } } }; 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 (

{error}

); } return (
{showLogin && (

RDP

update("domain", e.target.value) } /> update("username", e.target.value) } /> update("password", e.target.value) } /> {/* update("pcb", e.target.value)} /> */} {/* update("kdcProxyUrl", e.target.value) } /> */} {/*
update("enableClipboard", checked === true) } />
*/}
)}
{/* */}
{moduleReady && ( )}
); } function Field({ label, id, children }: { label: string; id: string; children: React.ReactNode; }) { return (
{children}
); }