mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
* 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
651 lines
20 KiB
TypeScript
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;
|