From 404608867f3288d85b45469f0259048fdce6d605 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Tue, 25 Aug 2026 01:40:42 +0800 Subject: [PATCH] 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. --- src/ui/features/guacamole/GuacamoleApp.tsx | 93 +++++++-- src/ui/locales/en.json | 5 + src/ui/shell/tabUtils.tsx | 7 + src/ui/sidebar/QuickConnectPanel.tsx | 183 +++++++++++++----- src/ui/sidebar/quick-connect-host.ts | 54 +++++- .../tests/sidebar/quick-connect-host.test.ts | 71 +++++++ 6 files changed, 349 insertions(+), 64 deletions(-) diff --git a/src/ui/features/guacamole/GuacamoleApp.tsx b/src/ui/features/guacamole/GuacamoleApp.tsx index e255e072..223dcfa0 100644 --- a/src/ui/features/guacamole/GuacamoleApp.tsx +++ b/src/ui/features/guacamole/GuacamoleApp.tsx @@ -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( - 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(null); + const [hostConfig, setHostConfig] = useState( + 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( }) .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( return ( ; 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, ]); diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index e5796153..3f729f75 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -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" }, diff --git a/src/ui/shell/tabUtils.tsx b/src/ui/shell/tabUtils.tsx index 9999fc7c..216bce99 100644 --- a/src/ui/shell/tabUtils.tsx +++ b/src/ui/shell/tabUtils.tsx @@ -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 + } />, ); diff --git a/src/ui/sidebar/QuickConnectPanel.tsx b/src/ui/sidebar/QuickConnectPanel.tsx index f6a33e52..f15d3b69 100644 --- a/src/ui/sidebar/QuickConnectPanel.tsx +++ b/src/ui/sidebar/QuickConnectPanel.tsx @@ -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 = { + 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("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 (
+
+ +
+ {(["ssh", "rdp", "vnc"] as const).map((type) => ( + + ))} +
+
-
- -
- {(["password", "key", "credential"] as const).map((type) => ( - - ))} + {!isDesktop && ( +
+ +
+ {(["password", "key", "credential"] as const).map((type) => ( + + ))} +
-
- {authType === "password" && ( + )} + {(isDesktop || authType === "password") && (
)} - {authType === "key" && ( + {isDesktop && protocol === "rdp" && ( +
+ + setDomain(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") connectDefault(); + }} + className="h-7 text-xs" + /> +
+ )} + {!isDesktop && authType === "key" && (
)} - {authType === "credential" && ( + {!isDesktop && authType === "credential" && (
)}
- - + {isDesktop ? ( + + ) : ( + <> + + + + )}
diff --git a/src/ui/sidebar/quick-connect-host.ts b/src/ui/sidebar/quick-connect-host.ts index d78bd934..50b18a27 100644 --- a/src/ui/sidebar/quick-connect-host.ts +++ b/src/ui/sidebar/quick-connect-host.ts @@ -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): 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, + }; +} diff --git a/src/ui/tests/sidebar/quick-connect-host.test.ts b/src/ui/tests/sidebar/quick-connect-host.test.ts index be5d8314..11a943d9 100644 --- a/src/ui/tests/sidebar/quick-connect-host.test.ts +++ b/src/ui/tests/sidebar/quick-connect-host.test.ts @@ -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", + }); + }); +});