feat: quick connect for RDP and VNC (#1335)

The Quick Connect panel gets a protocol switch. RDP/VNC quick hosts are
built like SSH ones (never saved) and opened as regular remote desktop
tabs; GuacamoleApp mints their token from the typed fields through the
existing /guacamole/token endpoint instead of a host-row lookup.
This commit is contained in:
ZacharyZcR
2026-08-25 01:40:42 +08:00
committed by GitHub
parent 672f5ba80b
commit 404608867f
6 changed files with 349 additions and 64 deletions
+78 -15
View File
@@ -21,7 +21,7 @@ import {
isElectron,
} from "@/main-axios.ts";
import { readConfiguredDimension } from "@/features/guacamole/guacamole-display-size.ts";
import { parseGuacamoleConfig } from "@/api/guacamole-api";
import { getGuacamoleToken, parseGuacamoleConfig } from "@/api/guacamole-api";
import { resolveConnectionOrigin } from "@/lib/connection-origin.ts";
import { useTranslation } from "react-i18next";
import { GuacamoleToolbar } from "@/features/guacamole/GuacamoleToolbar.tsx";
@@ -52,8 +52,15 @@ interface GuacamoleAppProps {
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 GuacamoleApp needs from a host that has no database row. */
export type GuacamoleQuickHost = GuacamoleAppInnerProps["hostConfig"] & {
name?: string;
};
export interface GuacamoleAppHandle {
disconnect: () => void;
isConnected: () => boolean;
@@ -62,14 +69,31 @@ export interface GuacamoleAppHandle {
}
const GuacamoleApp = React.forwardRef<GuacamoleAppHandle, GuacamoleAppProps>(
function GuacamoleApp({ hostId, tabId, protocol, isVisible = true }, ref) {
function GuacamoleApp(
{ hostId, tabId, protocol, isVisible = true, quickConnectHost },
ref,
) {
const { t } = useTranslation();
const defaults = useConnectionDefaults();
const [hostConfig, setHostConfig] = useState<SSHHost | null>(null);
const [hostConfig, setHostConfig] = useState<GuacamoleQuickHost | null>(
null,
);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!defaults.ready) return;
if (quickConnectHost) {
const connectionType = protocol ?? quickConnectHost.connectionType;
setHostConfig({
...quickConnectHost,
guacamoleConfig: resolveConnectionDefaults(
connectionType === "rdp" ? defaults.rdp : {},
{},
),
});
setLoading(false);
return;
}
if (!hostId) {
setLoading(false);
return;
@@ -93,7 +117,7 @@ const GuacamoleApp = React.forwardRef<GuacamoleAppHandle, GuacamoleAppProps>(
})
.catch(() => setHostConfig(null))
.finally(() => setLoading(false));
}, [hostId, protocol, defaults.ready, defaults.rdp]);
}, [hostId, protocol, defaults.ready, defaults.rdp, quickConnectHost]);
if (loading) {
return (
@@ -117,9 +141,9 @@ const GuacamoleApp = React.forwardRef<GuacamoleAppHandle, GuacamoleAppProps>(
return (
<ConnectionLogProvider>
<GuacamoleAppInner
hostId={parseInt(hostId, 10)}
hostId={quickConnectHost ? 0 : parseInt(hostId, 10)}
hostConfig={hostConfig}
hostName={hostConfig.name || hostConfig.ip || String(hostId)}
hostName={hostConfig.name || hostConfig.ip || String(hostId ?? "")}
tabId={tabId}
protocol={protocol}
isVisible={isVisible}
@@ -134,7 +158,18 @@ interface GuacamoleAppInnerProps {
hostId: number;
hostConfig: Pick<
SSHHost,
"connectionType" | "domain" | "guacamoleConfig" | "rdpAuthType" | "syncId"
| "connectionType"
| "domain"
| "guacamoleConfig"
| "rdpAuthType"
| "syncId"
| "ip"
| "rdpPort"
| "vncPort"
| "rdpUser"
| "rdpPassword"
| "vncUser"
| "vncPassword"
>;
hostName: string;
tabId?: string;
@@ -253,16 +288,44 @@ const GuacamoleAppInner = React.forwardRef<
type: resolvedProtocolForConnect.toUpperCase(),
}),
});
const result = await getGuacamoleTokenFromHost(
hostId,
protocol,
promptedCredentials ?? undefined,
hostConfig.syncId,
);
// 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:
resolvedProtocolForConnect === "vnc"
? hostConfig.vncPort
: hostConfig.rdpPort,
username:
resolvedProtocolForConnect === "vnc"
? hostConfig.vncUser
: hostConfig.rdpUser,
password:
resolvedProtocolForConnect === "vnc"
? hostConfig.vncPassword
: hostConfig.rdpPassword,
domain: hostConfig.domain,
ignoreCert: true,
guacamoleConfig: parseGuacamoleConfig(hostConfig.guacamoleConfig),
})
: await getGuacamoleTokenFromHost(
hostId,
protocol,
promptedCredentials ?? undefined,
hostConfig.syncId,
);
if (result) {
setToken(result.token);
setGuacamoleConnectionId(result.guacamoleConnectionId ?? null);
logActivity(resolvedProtocolForConnect, hostId, hostName).catch(() => {});
if (hostId !== 0) {
logActivity(resolvedProtocolForConnect, hostId, hostName).catch(
() => {},
);
}
}
}, [
hostId,
@@ -270,7 +333,7 @@ const GuacamoleAppInner = React.forwardRef<
protocol,
promptedCredentials,
resolvedProtocolForConnect,
hostConfig.syncId,
hostConfig,
addLog,
t,
]);
+5
View File
@@ -3686,6 +3686,11 @@
"privateKeyPlaceholder": "Paste private key...",
"credentialLabel": "Credential",
"credentialPlaceholder": "Select a saved credential",
"protocolLabel": "Protocol",
"domainLabel": "Domain",
"domainPlaceholder": "optional",
"connectToRdp": "Connect via RDP",
"connectToVnc": "Connect via VNC",
"connectToTerminal": "Connect to Terminal",
"connectToFiles": "Connect to Files"
},
+7
View File
@@ -40,6 +40,10 @@ import type {
import type { GuacamoleAppHandle } from "@/features/guacamole/GuacamoleApp";
import { useIsMobile } from "@/hooks/use-mobile";
import type { Tab, TabType, Host } from "@/types/ui-types";
import {
isQuickConnectHost,
quickConnectGuacHost,
} from "@/sidebar/quick-connect-host";
import type { SSHHost } from "@/types";
import { useTabsSafe } from "@/shell/TabContext";
import {
@@ -586,6 +590,9 @@ export function renderTabContent(
tabId={tab.id}
protocol={tab.type as "rdp" | "vnc" | "telnet"}
isVisible={isVisible}
quickConnectHost={
isQuickConnectHost(host) ? quickConnectGuacHost(host) : undefined
}
/>,
);
+136 -47
View File
@@ -1,20 +1,38 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Eye, EyeOff, FolderSearch, Terminal } from "lucide-react";
import {
Eye,
EyeOff,
FolderSearch,
Monitor,
MousePointerClick,
Terminal,
} from "lucide-react";
import { Input } from "@/components/input";
import type { Host } from "@/types/ui-types";
import { getCredentials } from "@/api/credentials-api";
import { mapCredentials } from "./HostManagerData";
import { createQuickConnectHost } from "./quick-connect-host";
import {
createQuickConnectHost,
type QuickConnectProtocol,
} from "./quick-connect-host";
const DEFAULT_PORTS: Record<QuickConnectProtocol, string> = {
ssh: "22",
rdp: "3389",
vnc: "5900",
};
interface QuickConnectPanelProps {
onConnect: (host: Host, type: "terminal" | "files") => void;
onConnect: (host: Host, type: "terminal" | "files" | "rdp" | "vnc") => void;
}
export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
const { t } = useTranslation();
const [host, setHost] = useState("");
const [protocol, setProtocol] = useState<QuickConnectProtocol>("ssh");
const [port, setPort] = useState("22");
const [domain, setDomain] = useState("");
const [username, setUsername] = useState("root");
const [authType, setAuthType] = useState<"password" | "key" | "credential">(
"password",
@@ -33,23 +51,56 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
.catch(() => {});
}, []);
const connect = (type: "terminal" | "files") => {
if (!host || !username) return;
const isDesktop = protocol !== "ssh";
const switchProtocol = (next: QuickConnectProtocol) => {
// Keep a port the user typed; only swap the protocol default.
if (port === DEFAULT_PORTS[protocol]) setPort(DEFAULT_PORTS[next]);
setProtocol(next);
};
const connect = (type: "terminal" | "files" | "rdp" | "vnc") => {
if (!host) return;
if (!isDesktop && !username) return;
const hostConfig = createQuickConnectHost({
ip: host,
port: parseInt(port) || 22,
port: parseInt(port) || parseInt(DEFAULT_PORTS[protocol]),
username,
authType,
authType: isDesktop ? "password" : authType,
password,
key: privateKey,
credentialId,
protocol,
domain: domain || undefined,
});
onConnect(hostConfig, type);
};
const connectDefault = () => connect(isDesktop ? protocol : "terminal");
return (
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
<div className="flex flex-col gap-3 p-3">
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.quickConnect.protocolLabel")}
</label>
<div className="flex gap-1">
{(["ssh", "rdp", "vnc"] as const).map((type) => (
<button
key={type}
onClick={() => switchProtocol(type)}
className={`flex-1 py-1 text-[10px] font-semibold border transition-colors uppercase ${
protocol === type
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
: "border-border text-muted-foreground hover:text-foreground"
}`}
>
{type}
</button>
))}
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.quickConnect.hostLabel")}
@@ -59,7 +110,7 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
value={host}
onChange={(e) => setHost(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") connect("terminal");
if (e.key === "Enter") connectDefault();
}}
className="h-7 text-xs"
/>
@@ -73,7 +124,7 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
value={port}
onChange={(e) => setPort(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") connect("terminal");
if (e.key === "Enter") connectDefault();
}}
className="h-7 text-xs"
/>
@@ -93,32 +144,34 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
}}
onChange={(e) => setUsername(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") connect("terminal");
if (e.key === "Enter") connectDefault();
}}
className="h-7 text-xs"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.quickConnect.authLabel")}
</label>
<div className="flex gap-1">
{(["password", "key", "credential"] as const).map((type) => (
<button
key={type}
onClick={() => setAuthType(type)}
className={`flex-1 py-1 text-[10px] font-semibold border transition-colors capitalize ${
authType === type
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
: "border-border text-muted-foreground hover:text-foreground"
}`}
>
{type}
</button>
))}
{!isDesktop && (
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.quickConnect.authLabel")}
</label>
<div className="flex gap-1">
{(["password", "key", "credential"] as const).map((type) => (
<button
key={type}
onClick={() => setAuthType(type)}
className={`flex-1 py-1 text-[10px] font-semibold border transition-colors capitalize ${
authType === type
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
: "border-border text-muted-foreground hover:text-foreground"
}`}
>
{type}
</button>
))}
</div>
</div>
</div>
{authType === "password" && (
)}
{(isDesktop || authType === "password") && (
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.quickConnect.passwordLabel")}
@@ -132,7 +185,7 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") connect("terminal");
if (e.key === "Enter") connectDefault();
}}
className="h-7 text-xs pr-8"
/>
@@ -149,7 +202,23 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
</div>
</div>
)}
{authType === "key" && (
{isDesktop && protocol === "rdp" && (
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.quickConnect.domainLabel")}
</label>
<Input
placeholder={t("newUi.sidebar.quickConnect.domainPlaceholder")}
value={domain}
onChange={(e) => setDomain(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") connectDefault();
}}
className="h-7 text-xs"
/>
</div>
)}
{!isDesktop && authType === "key" && (
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.quickConnect.privateKeyLabel")}
@@ -164,7 +233,7 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
/>
</div>
)}
{authType === "credential" && (
{!isDesktop && authType === "credential" && (
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.quickConnect.credentialLabel")}
@@ -191,20 +260,40 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
</div>
)}
<div className="flex flex-col gap-1.5 pt-1">
<button
onClick={() => connect("terminal")}
className="flex items-center justify-center gap-1.5 h-7 w-full border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-xs font-semibold hover:bg-accent-brand/20 transition-colors"
>
<Terminal className="size-3.5" />
{t("newUi.sidebar.quickConnect.connectToTerminal")}
</button>
<button
onClick={() => connect("files")}
className="flex items-center justify-center gap-1.5 h-7 w-full border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-xs font-semibold hover:bg-accent-brand/20 transition-colors"
>
<FolderSearch className="size-3.5" />
{t("newUi.sidebar.quickConnect.connectToFiles")}
</button>
{isDesktop ? (
<button
onClick={() => connect(protocol)}
className="flex items-center justify-center gap-1.5 h-7 w-full border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-xs font-semibold hover:bg-accent-brand/20 transition-colors"
>
{protocol === "rdp" ? (
<Monitor className="size-3.5" />
) : (
<MousePointerClick className="size-3.5" />
)}
{t(
protocol === "rdp"
? "newUi.sidebar.quickConnect.connectToRdp"
: "newUi.sidebar.quickConnect.connectToVnc",
)}
</button>
) : (
<>
<button
onClick={() => connect("terminal")}
className="flex items-center justify-center gap-1.5 h-7 w-full border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-xs font-semibold hover:bg-accent-brand/20 transition-colors"
>
<Terminal className="size-3.5" />
{t("newUi.sidebar.quickConnect.connectToTerminal")}
</button>
<button
onClick={() => connect("files")}
className="flex items-center justify-center gap-1.5 h-7 w-full border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-xs font-semibold hover:bg-accent-brand/20 transition-colors"
>
<FolderSearch className="size-3.5" />
{t("newUi.sidebar.quickConnect.connectToFiles")}
</button>
</>
)}
</div>
</div>
</div>
+52 -2
View File
@@ -1,14 +1,47 @@
import type { SSHHostData } from "@/types";
import type { Host } from "@/types/ui-types";
import type { GuacamoleQuickHost } from "@/features/guacamole/GuacamoleApp";
export type QuickConnectProtocol = "ssh" | "rdp" | "vnc";
type QuickConnectInput = Pick<
Host,
"ip" | "port" | "username" | "authType" | "password" | "key" | "credentialId"
>;
> & { protocol?: QuickConnectProtocol; domain?: string };
export const QUICK_CONNECT_ID_PREFIX = "quick-connect-";
export function isQuickConnectHost(host: Pick<Host, "id">): boolean {
return host.id.startsWith(QUICK_CONNECT_ID_PREFIX);
}
export function createQuickConnectHost(input: QuickConnectInput): Host {
const protocol = input.protocol ?? "ssh";
if (protocol !== "ssh") {
return {
...createQuickConnectHost({ ...input, protocol: "ssh", port: 22 }),
port: input.port,
enableTerminal: false,
enableCommandHistory: false,
enableFileManager: false,
enableTunnel: false,
enableDocker: false,
enableTerminalToolbar: false,
enableSsh: false,
enableRdp: protocol === "rdp",
enableVnc: protocol === "vnc",
rdpPort: protocol === "rdp" ? input.port : 3389,
vncPort: protocol === "vnc" ? input.port : 5900,
rdpAuthType: "direct",
rdpUser: protocol === "rdp" ? input.username : undefined,
rdpPassword: protocol === "rdp" ? input.password : undefined,
domain: protocol === "rdp" ? input.domain : undefined,
vncUser: protocol === "vnc" ? input.username : undefined,
vncPassword: protocol === "vnc" ? input.password : undefined,
};
}
return {
id: `quick-connect-${Date.now()}`,
id: `${QUICK_CONNECT_ID_PREFIX}${Date.now()}`,
name: `${input.username}@${input.ip}`,
ip: input.ip,
port: input.port,
@@ -87,3 +120,20 @@ export function quickConnectHostToPayload(host: Host): SSHHostData {
telnetPort: host.telnetPort,
};
}
/** The slice of a quick-connect host that GuacamoleApp mints a token from. */
export function quickConnectGuacHost(host: Host): GuacamoleQuickHost {
return {
name: host.name,
ip: host.ip,
connectionType: host.enableVnc ? "vnc" : "rdp",
domain: host.domain,
rdpPort: host.rdpPort,
vncPort: host.vncPort,
rdpAuthType: host.rdpAuthType,
rdpUser: host.rdpUser,
rdpPassword: host.rdpPassword,
vncUser: host.vncUser,
vncPassword: host.vncPassword,
};
}
@@ -1,6 +1,8 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createQuickConnectHost,
isQuickConnectHost,
quickConnectGuacHost,
quickConnectHostToPayload,
} from "../../sidebar/quick-connect-host";
@@ -47,3 +49,72 @@ describe("quick connect host", () => {
expect(payload.key).toBeUndefined();
});
});
describe("createQuickConnectHost for remote desktop protocols", () => {
it("builds an SSH host by default", () => {
const host = createQuickConnectHost({
ip: "10.0.0.1",
port: 2222,
username: "root",
authType: "password",
password: "pw",
});
expect(isQuickConnectHost(host)).toBe(true);
expect(host).toMatchObject({
enableSsh: true,
enableRdp: false,
sshPort: 2222,
password: "pw",
});
});
it("builds an RDP host that GuacamoleApp can mint a token from", () => {
const host = createQuickConnectHost({
ip: "10.0.0.2",
port: 3390,
username: "admin",
authType: "password",
password: "pw",
protocol: "rdp",
domain: "CORP",
});
expect(host).toMatchObject({
enableSsh: false,
enableRdp: true,
enableVnc: false,
rdpPort: 3390,
rdpUser: "admin",
rdpPassword: "pw",
domain: "CORP",
});
expect(quickConnectGuacHost(host)).toMatchObject({
ip: "10.0.0.2",
connectionType: "rdp",
rdpPort: 3390,
rdpUser: "admin",
rdpPassword: "pw",
domain: "CORP",
});
});
it("builds a VNC host with the password on the VNC fields", () => {
const host = createQuickConnectHost({
ip: "10.0.0.3",
port: 5901,
username: "",
authType: "password",
password: "vncpw",
protocol: "vnc",
});
expect(host).toMatchObject({
enableVnc: true,
vncPort: 5901,
vncPassword: "vncpw",
});
expect(quickConnectGuacHost(host)).toMatchObject({
connectionType: "vnc",
vncPort: 5901,
vncPassword: "vncpw",
});
});
});