Files
Termix/plugins/remote-desktop/src/frontend/GuacamoleApp.tsx
T
ZacharyZcR 3b9a1bacf0 fix(remote-desktop): probe the saved host guacd endpoint (#1481)
* fix(remote-desktop): probe the saved host guacd endpoint

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures
2026-09-27 00:11:09 -05:00

651 lines
20 KiB
TypeScript

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<GuacamoleAppHandle, GuacamoleAppProps>(
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 (
<div className="relative w-full h-full">
<ConnectionScreen status="connecting" message={t("common.loading")} />
</div>
);
}
if (!hostConfig || !hostId) {
return (
<div className="relative w-full h-full">
<ConnectionScreen
status="disconnected"
message={t("remoteDesktop.hostNotFound")}
/>
</div>
);
}
return (
<ConnectionLogProvider>
<GuacamoleAppInner
hostId={quickConnectHost ? 0 : parseInt(hostId, 10)}
hostConfig={hostConfig}
hostName={hostConfig.name || hostConfig.ip || String(hostId ?? "")}
tabId={tabId}
protocol={protocol}
isVisible={isVisible}
ref={ref}
/>
</ConnectionLogProvider>
);
},
);
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<string | null>(null);
const [guacamoleConnectionId, setGuacamoleConnectionId] = useState<
string | null
>(null);
const [sessionLookup, setSessionLookup] = useState<{
id: string;
origin: ConnectionOrigin;
} | null>(null);
const [error, setError] = useState<string | null>(null);
const [connectionError, setConnectionError] = useState<string | null>(null);
const [isDisplayReady, setIsDisplayReady] = useState(false);
const [touchMode, setTouchMode] = useState<GuacamoleTouchMode | null>(() =>
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<GuacamoleDisplayHandle>(null);
const [displayZoom, setDisplayZoom] = useState(1);
const [filesystem, setFilesystem] = useState<Guacamole.Object | null>(null);
const [fileBrowserOpen, setFileBrowserOpen] = useState(false);
const [toolbarHidden, setToolbarHidden] = useState(false);
const [pendingUploads, setPendingUploads] = useState<File[]>([]);
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<void> => {
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 (
<Dialog
open={promptOpen}
onOpenChange={(open) => {
if (!open) setPromptOpen(false);
}}
>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{t("remoteDesktop.credentialPromptTitle")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("remoteDesktop.credentialPromptDescription")}
</DialogDescription>
</DialogHeader>
<form
className="flex flex-col gap-4 mt-1"
onSubmit={(e) => {
e.preventDefault();
setPromptedCredentials({
username: promptUsername,
password: promptPassword,
domain: promptDomain,
});
setPromptOpen(false);
}}
>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("hosts.guac.username")}
</label>
<Input
autoFocus
placeholder="Administrator"
value={promptUsername}
onChange={(e) => setPromptUsername(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("hosts.guac.domain")}
</label>
<Input
placeholder="WORKGROUP"
value={promptDomain}
onChange={(e) => setPromptDomain(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("hosts.guac.password")}
</label>
<PasswordInput
className="h-8 text-xs pr-8"
placeholder="••••••••"
value={promptPassword}
onChange={(e) => setPromptPassword(e.target.value)}
/>
</div>
<div className="flex items-center justify-end gap-2 mt-2">
<Button type="submit" variant="outline">
{t("remoteDesktop.connect")}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
if (error || !token) {
return (
<div className="relative w-full h-full">
<ConnectionScreen
status={error ? tokenRetry.status : "connecting"}
message={t("remoteDesktop.connecting", {
type: (
protocol ||
hostConfig.connectionType ||
"remote"
).toUpperCase(),
})}
attempt={tokenRetry.attempt}
maxAttempts={tokenRetry.maxAttempts}
nextRetryInMs={tokenRetry.nextRetryInMs}
onManualRetry={handleReconnect}
retryLabel={t("remoteDesktop.retry")}
/>
</div>
);
}
const resolvedProtocol = resolvedProtocolForConnect;
const configuredDpi = readConfiguredDimension(guacConfig.dpi);
const configuredWidth = readConfiguredDimension(guacConfig.width);
const configuredHeight = readConfiguredDimension(guacConfig.height);
return (
<div className="relative w-full h-full">
{(!isDisplayReady || connectionError) && (
<ConnectionScreen
status={connectionError ? "disconnected" : "connecting"}
message={t("remoteDesktop.connecting", {
type: resolvedProtocol.toUpperCase(),
})}
onManualRetry={handleReconnect}
retryLabel={t("remoteDesktop.reconnect")}
className="z-50"
/>
)}
<GuacamoleDisplay
key={`${token}-${touchMode}`}
ref={displayRef}
connectionConfig={{
token,
protocol: resolvedProtocol,
type: resolvedProtocol,
connectionOrigin: hostConfig.connectionOrigin,
width: configuredWidth,
height: configuredHeight,
dpi: configuredDpi,
}}
isVisible={isVisible}
touchMode={touchMode}
allowUpload={canUploadToRdpDrive(
allowUpload,
guacConfig.enableDrive === true,
filesystem !== null,
)}
onConnect={() => 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 && (
<GuacamoleFileBrowser
filesystem={filesystem}
allowUpload={allowUpload}
allowDownload={allowDownload}
pendingUploads={pendingUploads}
onPendingUploadsHandled={() => setPendingUploads([])}
onClose={() => setFileBrowserOpen(false)}
/>
)}
{hostConfig.showToolbar !== false && !toolbarHidden && (
<GuacamoleToolbar
displayRef={displayRef}
protocol={resolvedProtocol}
touchMode={touchMode}
hasFilesystem={filesystem !== null}
fileBrowserOpen={fileBrowserOpen}
onToggleFileBrowser={() => setFileBrowserOpen((open) => !open)}
onTouchModeChange={setTouchMode}
zoom={displayZoom}
onHide={() => setToolbarHidden(true)}
slotContext={
typeof hostId === "number"
? {
hostId,
sessionId: guacamoleConnectionId,
protocol: resolvedProtocol,
tabInstanceId: tabId,
}
: undefined
}
/>
)}
</div>
);
});
export default GuacamoleApp;