import { watchGuacamoleConnectionId } from "./guacamole-session-id"; import React, { useState, useEffect, useRef, useCallback, useImperativeHandle, } from "react"; import type Guacamole from "guacamole-common-js"; import { toast } from "sonner"; import { GuacamoleDisplay, type GuacamoleDisplayHandle, type GuacamoleTouchMode, } from "./GuacamoleDisplay.tsx"; import { getGuacamoleTokenFromHost, getGuacdStatus } from "./guacamole-api"; import { readConfiguredDimension } from "./guacamole-display-size.ts"; import { getGuacamoleToken } from "./guacamole-api"; import { useHost, useTranslation, useConnectionRetry, logActivity, } from "@termix/plugin-sdk/frontend"; import { Button, ConnectionLogProvider, ConnectionScreen, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, Input, PasswordInput, isElectron, linkedServerUrl, resolveConnectionOrigin, useConnectionLog, type ConnectionOrigin, } from "@termix/plugin-sdk/ui"; import type { GuacamoleConfig } from "./guacamole-config"; import { errorMessage, hostRemoteOptions, type Protocol, type RemoteHostLogin, } from "./host-remote"; import { GuacamoleToolbar } from "./GuacamoleToolbar.tsx"; import { GuacamoleFileBrowser } from "./GuacamoleFileBrowser.tsx"; import { describeUploadError } from "./guacamole-filesystem.ts"; import { canUploadToRdpDrive } from "./guacamole-file-drop.ts"; import { needsRdpCredentialPrompt } from "./rdp-credential-prompt"; interface GuacamoleAppProps { hostId?: string; tabId?: string; protocol?: "rdp" | "vnc" | "telnet"; isVisible?: boolean; /** A quick-connect host: never saved, so the token is minted from its fields. */ quickConnectHost?: GuacamoleQuickHost; } /** * What a session needs from its host. A saved host brings its options; a * quick-connect host has no row, so it also carries its address and login. */ export interface GuacamoleHostConfig { name?: string; ip: string; connectionType?: Protocol; domain?: string; guacamoleConfig?: GuacamoleConfig; rdpAuthType?: string; authOverrides?: RemoteHostLogin["authOverrides"]; syncId?: string | null; connectionOrigin?: "local" | "remote" | null; showToolbar?: boolean; port?: number; username?: string; password?: string; } export type GuacamoleQuickHost = GuacamoleHostConfig; function savedHostConfig( host: RemoteHostLogin, protocol: Protocol | undefined, ): GuacamoleHostConfig { const options = hostRemoteOptions(host); return { name: host.name ?? undefined, ip: host.ip ?? "", connectionType: protocol ?? (options.enableRdp ? "rdp" : options.enableVnc ? "vnc" : "telnet"), domain: host.domain, guacamoleConfig: options.guacamoleConfig, rdpAuthType: host.rdpAuthType, authOverrides: host.authOverrides, syncId: host.syncId, connectionOrigin: host.connectionOrigin, showToolbar: options.enableToolbar, }; } export interface GuacamoleAppHandle { disconnect: () => void; isConnected: () => boolean; /** Reconnects, from the tab bar's refresh. */ refresh: () => void; } const GuacamoleApp = React.forwardRef( function GuacamoleApp( { hostId, tabId, protocol, isVisible = true, quickConnectHost }, ref, ) { const { t } = useTranslation(); const savedHost = useHost(quickConnectHost ? undefined : hostId); const hostConfig: GuacamoleHostConfig | null = quickConnectHost ? quickConnectHost : savedHost ? savedHostConfig(savedHost as RemoteHostLogin, protocol) : null; // The host list loads with the shell; a tab restored before it arrives // waits for it instead of reporting the host missing. const [waited, setWaited] = useState(false); useEffect(() => { const timer = setTimeout(() => setWaited(true), 3000); return () => clearTimeout(timer); }, []); const loading = !hostConfig && !!hostId && !waited; if (loading) { return (
); } if (!hostConfig || !hostId) { return (
); } return ( ); }, ); interface GuacamoleAppInnerProps { hostId: number; hostConfig: GuacamoleHostConfig; hostName: string; tabId?: string; protocol?: "rdp" | "vnc" | "telnet"; isVisible: boolean; } const GuacamoleAppInner = React.forwardRef< GuacamoleAppHandle, GuacamoleAppInnerProps >(function GuacamoleAppInner( { hostId, hostConfig, hostName, tabId, protocol, isVisible }, ref, ) { const { t } = useTranslation(); const { addLog, clearLogs } = useConnectionLog(); const [token, setToken] = useState(null); const [guacamoleConnectionId, setGuacamoleConnectionId] = useState< string | null >(null); const [sessionLookup, setSessionLookup] = useState<{ id: string; origin: ConnectionOrigin; } | null>(null); const [error, setError] = useState(null); const [connectionError, setConnectionError] = useState(null); const [isDisplayReady, setIsDisplayReady] = useState(false); const [touchMode, setTouchMode] = useState(() => typeof window !== "undefined" && (navigator.maxTouchPoints > 0 || "ontouchstart" in window) ? "touchscreen" : null, ); useEffect(() => { if (!isDisplayReady || !sessionLookup) return; return watchGuacamoleConnectionId( sessionLookup.id, sessionLookup.origin, setGuacamoleConnectionId, ); }, [isDisplayReady, sessionLookup]); const displayRef = useRef(null); const [displayZoom, setDisplayZoom] = useState(1); const [filesystem, setFilesystem] = useState(null); const [fileBrowserOpen, setFileBrowserOpen] = useState(false); const [toolbarHidden, setToolbarHidden] = useState(false); const [pendingUploads, setPendingUploads] = useState([]); const guacConfig = hostConfig.guacamoleConfig ?? {}; const allowUpload = guacConfig.disableUpload !== true; const allowDownload = guacConfig.disableDownload !== true; // Prefer the browsable filesystem's current directory. guacd may expose the // RDP drive only through the connection-level file stream, in which case the // standard direct upload still lands in the redirected drive. const handleDropFiles = useCallback( (files: File[]) => { if (filesystem) { setPendingUploads(files); setFileBrowserOpen(true); return; } void (async () => { for (const file of files) { try { const display = displayRef.current; if (!display) throw new Error("RDP session is not ready"); await display.uploadFile(file); toast.success( t("remoteDesktop.files.uploaded", { name: file.name }), ); } catch (error) { toast.error( describeUploadError(error, (key) => t(`remoteDesktop.files.${key}`, { name: file.name }), ), ); } } })(); }, [filesystem, t], ); const handleDropUnavailable = useCallback(() => { toast.error( t( allowUpload ? "remoteDesktop.files.driveUnavailable" : "remoteDesktop.files.uploadDisabled", ), ); }, [allowUpload, t]); const resolvedProtocolForConnect = (protocol ?? hostConfig.connectionType ?? "rdp") as "rdp" | "vnc" | "telnet"; const needsCredentialPrompt = needsRdpCredentialPrompt({ protocol: resolvedProtocolForConnect, rdpAuthType: hostConfig.rdpAuthType, authOverrides: hostConfig.authOverrides, }); const [promptedCredentials, setPromptedCredentials] = useState<{ username: string; password: string; domain: string; } | null>(null); const [promptOpen, setPromptOpen] = useState(needsCredentialPrompt); const [promptUsername, setPromptUsername] = useState(""); const [promptPassword, setPromptPassword] = useState(""); const [promptDomain, setPromptDomain] = useState(hostConfig.domain ?? ""); // Assigned below, once handleReconnect exists; the shell's tab refresh calls it. const reconnectRef = useRef<() => void>(() => {}); useImperativeHandle(ref, () => ({ disconnect: () => displayRef.current?.disconnect(), isConnected: () => displayRef.current?.isConnected() === true, refresh: () => reconnectRef.current(), })); const fetchToken = useCallback(async (): Promise => { setToken(null); setIsDisplayReady(false); setSessionLookup(null); setGuacamoleConnectionId(null); setError(null); // Outside Electron there is only one backend, so the origin is moot and // the API layer ignores it; inside, it decides which backend mints the // token and therefore has to match the one the session will run on. let resolvedOrigin: ConnectionOrigin = "local"; if (isElectron()) { resolvedOrigin = await resolveConnectionOrigin( { connectionOrigin: hostConfig.connectionOrigin }, { defaultRemote: true }, ); if (resolvedOrigin === "remote") { if (!(await linkedServerUrl())) { throw new Error(t("errors.remoteServerRequired")); } } } addLog({ type: "info", stage: "guac_guacd", message: t("remoteDesktop.checkingGuacd"), }); const status = await getGuacdStatus(resolvedOrigin, { hostId, protocol: resolvedProtocolForConnect, syncId: hostConfig.syncId, }); if (status.enabled === false) { throw new Error(t("remoteDesktop.disabled")); } if (status.guacd.status !== "connected") { throw new Error(t("remoteDesktop.guacdUnavailable")); } addLog({ type: "info", stage: "guac_token", message: t("remoteDesktop.requestingToken", { type: resolvedProtocolForConnect.toUpperCase(), }), }); // hostId 0 is a quick-connect host: nothing to look up, mint the token // straight from what the user typed. It cannot be shared or logged as // host activity because there is no host row. const result = hostId === 0 ? await getGuacamoleToken( { protocol: resolvedProtocolForConnect, hostname: hostConfig.ip, port: hostConfig.port, username: hostConfig.username, password: hostConfig.password, domain: hostConfig.domain, ignoreCert: true, guacamoleConfig: hostConfig.guacamoleConfig, }, resolvedOrigin, ) : await getGuacamoleTokenFromHost( hostId, resolvedOrigin, protocol, promptedCredentials ?? undefined, hostConfig.syncId, tabId, ); if (result) { setSessionLookup( result.termixConnectId ? { id: result.termixConnectId, origin: resolvedOrigin } : null, ); setToken(result.token); setGuacamoleConnectionId(result.guacamoleConnectionId ?? null); if (hostId !== 0) { logActivity(resolvedProtocolForConnect, hostId, hostName).catch( () => {}, ); } } }, [ hostId, hostName, protocol, promptedCredentials, resolvedProtocolForConnect, hostConfig, tabId, addLog, t, ]); const tokenRetry = useConnectionRetry({ connect: async () => { try { await fetchToken(); tokenRetryRef.current.markConnected(); } catch (err: unknown) { const message = errorMessage(err, t("remoteDesktop.failedToConnect")); setError(message || t("remoteDesktop.failedToConnect")); addLog({ type: "error", stage: "error", message }); tokenRetryRef.current.markFailed(); } }, enabled: !needsCredentialPrompt || !!promptedCredentials, autoStart: false, }); const tokenRetryRef = useRef(tokenRetry); tokenRetryRef.current = tokenRetry; useEffect(() => { if (needsCredentialPrompt && !promptedCredentials) { setPromptOpen(true); return; } clearLogs(); tokenRetryRef.current.reset(); tokenRetryRef.current.retryNow(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [hostId, protocol, needsCredentialPrompt, promptedCredentials]); const handleReconnect = useCallback(() => { setConnectionError(null); setError(null); setToken(null); setIsDisplayReady(false); if (needsCredentialPrompt) { setPromptedCredentials(null); setPromptUsername(""); setPromptPassword(""); setPromptDomain(hostConfig.domain ?? ""); setPromptOpen(true); return; } clearLogs(); tokenRetryRef.current.reset(); tokenRetryRef.current.retryNow(); }, [needsCredentialPrompt, hostConfig.domain, clearLogs]); reconnectRef.current = handleReconnect; if (promptOpen) { return ( { if (!open) setPromptOpen(false); }} > {t("remoteDesktop.credentialPromptTitle")} {t("remoteDesktop.credentialPromptDescription")}
{ e.preventDefault(); setPromptedCredentials({ username: promptUsername, password: promptPassword, domain: promptDomain, }); setPromptOpen(false); }} >
setPromptUsername(e.target.value)} />
setPromptDomain(e.target.value)} />
setPromptPassword(e.target.value)} />
); } if (error || !token) { return (
); } const resolvedProtocol = resolvedProtocolForConnect; const configuredDpi = readConfiguredDimension(guacConfig.dpi); const configuredWidth = readConfiguredDimension(guacConfig.width); const configuredHeight = readConfiguredDimension(guacConfig.height); return (
{(!isDisplayReady || connectionError) && ( )} setIsDisplayReady(true)} onError={(err) => { setConnectionError(err); addLog({ type: "error", stage: "error", message: err }); }} onStageChange={(stage) => { const type = resolvedProtocol.toUpperCase(); switch (stage) { case "guac_connecting": addLog({ type: "info", stage, message: t("remoteDesktop.openingSession", { type }), }); break; case "guac_handshake": addLog({ type: "info", stage, message: t("remoteDesktop.negotiating", { type }), }); break; case "guac_ready": addLog({ type: "success", stage, message: t("remoteDesktop.sessionReady", { type }), }); break; default: break; } }} onZoomChange={setDisplayZoom} onFilesystem={setFilesystem} onDropFiles={handleDropFiles} onDropUnavailable={handleDropUnavailable} /> {filesystem && fileBrowserOpen && ( setPendingUploads([])} onClose={() => setFileBrowserOpen(false)} /> )} {hostConfig.showToolbar !== false && !toolbarHidden && ( setFileBrowserOpen((open) => !open)} onTouchModeChange={setTouchMode} zoom={displayZoom} onHide={() => setToolbarHidden(true)} slotContext={ typeof hostId === "number" ? { hostId, sessionId: guacamoleConnectionId, protocol: resolvedProtocol, tabInstanceId: tabId, } : undefined } /> )}
); }); export default GuacamoleApp;