feat: finish the remote-desktop plugin (B14)

This commit is contained in:
LukeGus committed 2026-09-24 01:02:45 -05:00
1 parent 0af2c94983
commit 14eb558b85
215 files changed
+30029 -3921

No files matched your search

@@ -1,5 +1,4 @@
import { watchGuacamoleConnectionId } from "./guacamole-session-id";
import { getErrorMessage } from "@/lib/error-message.js";
import React, {
useState,
useEffect,
@@ -14,40 +13,42 @@ import {
type GuacamoleDisplayHandle,
type GuacamoleTouchMode,
} from "./GuacamoleDisplay.tsx";
import { getSSHHosts, logActivity, isElectron } from "@/main-axios";
import { getGuacamoleTokenFromHost, getGuacdStatus } from "./guacamole-api";
import { readConfiguredDimension } from "./guacamole-display-size.ts";
import { getGuacamoleToken, parseGuacamoleConfig } from "./guacamole-api";
import {
resolveConnectionOrigin,
type ConnectionOrigin,
} from "@/lib/connection-origin.ts";
import { getGuacamoleToken } from "./guacamole-api";
import {
useHost,
useTranslation,
useConnectionRetry,
} from "@termix/plugin-sdk/frontend";
import { GuacamoleToolbar } from "./GuacamoleToolbar.tsx";
import { GuacamoleFileBrowser } from "./GuacamoleFileBrowser.tsx";
import { describeUploadError } from "./guacamole-filesystem.ts";
import { canUploadToRdpDrive } from "./guacamole-file-drop.ts";
import { Button } from "@/components/button.tsx";
import { Input } from "@/components/input.tsx";
import { PasswordInput } from "@/components/password-input.tsx";
import {
Button,
ConnectionLogProvider,
ConnectionScreen,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/dialog.tsx";
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
import {
ConnectionLogProvider,
Input,
PasswordInput,
isElectron,
logActivity,
resolveConnectionOrigin,
useConnectionLog,
} from "@/ssh/connection-log/ConnectionLogContext.tsx";
import type { SSHHost } from "@/types";
import { useConnectionDefaults } from "@/contexts/ConnectionDefaultsContext";
import { resolveConnectionDefaults } from "@/lib/connection-defaults";
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 {
@@ -59,10 +60,48 @@ interface GuacamoleAppProps {
quickConnectHost?: GuacamoleQuickHost;
}
/** What GuacamoleApp needs from a host that has no database row. */
export type GuacamoleQuickHost = GuacamoleAppInnerProps["hostConfig"] & {
/**
* 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;
@@ -77,50 +116,20 @@ const GuacamoleApp = React.forwardRef<GuacamoleAppHandle, GuacamoleAppProps>(
ref,
) {
const { t } = useTranslation();
const defaults = useConnectionDefaults();
const [hostConfig, setHostConfig] = useState<GuacamoleQuickHost | null>(
null,
);
const [loading, setLoading] = useState(true);
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(() => {
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;
}
getSSHHosts()
.then((hosts) => {
const host = hosts.find((h) => h.id === parseInt(hostId, 10));
if (!host) {
setHostConfig(null);
return;
}
const connectionType = protocol ?? host.connectionType;
const protocolDefaults = connectionType === "rdp" ? defaults.rdp : {};
setHostConfig({
...host,
guacamoleConfig: resolveConnectionDefaults(
protocolDefaults,
parseGuacamoleConfig(host.guacamoleConfig),
),
});
})
.catch(() => setHostConfig(null))
.finally(() => setLoading(false));
}, [hostId, protocol, defaults.ready, defaults.rdp, quickConnectHost]);
const timer = setTimeout(() => setWaited(true), 3000);
return () => clearTimeout(timer);
}, []);
const loading = !hostConfig && !!hostId && !waited;
if (loading) {
return (
@@ -159,23 +168,7 @@ const GuacamoleApp = React.forwardRef<GuacamoleAppHandle, GuacamoleAppProps>(
interface GuacamoleAppInnerProps {
hostId: number;
hostConfig: Pick<
SSHHost,
| "connectionType"
| "domain"
| "guacamoleConfig"
| "rdpAuthType"
| "authOverrides"
| "syncId"
| "connectionOrigin"
| "ip"
| "rdpPort"
| "vncPort"
| "rdpUser"
| "rdpPassword"
| "vncUser"
| "vncPassword"
> & { enableTerminalToolbar?: boolean };
hostConfig: GuacamoleHostConfig;
hostName: string;
tabId?: string;
protocol?: "rdp" | "vnc" | "telnet";
@@ -223,7 +216,7 @@ const GuacamoleAppInner = React.forwardRef<
const [toolbarHidden, setToolbarHidden] = useState(false);
const [pendingUploads, setPendingUploads] = useState<File[]>([]);
const guacConfig = parseGuacamoleConfig(hostConfig.guacamoleConfig);
const guacConfig = hostConfig.guacamoleConfig ?? {};
const allowUpload = guacConfig.disableUpload !== true;
const allowDownload = guacConfig.disableDownload !== true;
@@ -311,10 +304,10 @@ const GuacamoleAppInner = React.forwardRef<
let resolvedOrigin: ConnectionOrigin = "local";
if (isElectron()) {
resolvedOrigin = await resolveConnectionOrigin({
connectionType: resolvedProtocolForConnect,
connectionOrigin: hostConfig.connectionOrigin,
});
resolvedOrigin = await resolveConnectionOrigin(
{ connectionOrigin: hostConfig.connectionOrigin },
{ defaultRemote: true },
);
if (resolvedOrigin === "remote") {
const remoteConfig = (await window.electronAPI?.invoke?.(
"get-remote-sync-config",
@@ -331,6 +324,9 @@ const GuacamoleAppInner = React.forwardRef<
message: t("remoteDesktop.checkingGuacd"),
});
const status = await getGuacdStatus(resolvedOrigin);
if (status.enabled === false) {
throw new Error(t("remoteDesktop.disabled"));
}
if (status.guacd.status !== "connected") {
throw new Error(t("remoteDesktop.guacdUnavailable"));
}
@@ -351,21 +347,12 @@ const GuacamoleAppInner = React.forwardRef<
{
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,
port: hostConfig.port,
username: hostConfig.username,
password: hostConfig.password,
domain: hostConfig.domain,
ignoreCert: true,
guacamoleConfig: parseGuacamoleConfig(hostConfig.guacamoleConfig),
guacamoleConfig: hostConfig.guacamoleConfig,
},
resolvedOrigin,
)
@@ -375,6 +362,7 @@ const GuacamoleAppInner = React.forwardRef<
protocol,
promptedCredentials ?? undefined,
hostConfig.syncId,
tabId,
);
if (result) {
setSessionLookup(
@@ -397,6 +385,7 @@ const GuacamoleAppInner = React.forwardRef<
promptedCredentials,
resolvedProtocolForConnect,
hostConfig,
tabId,
addLog,
t,
]);
@@ -407,10 +396,7 @@ const GuacamoleAppInner = React.forwardRef<
await fetchToken();
tokenRetryRef.current.markConnected();
} catch (err: unknown) {
const message = getErrorMessage(
err,
t("remoteDesktop.failedToConnect"),
);
const message = errorMessage(err, t("remoteDesktop.failedToConnect"));
setError(message || t("remoteDesktop.failedToConnect"));
addLog({ type: "error", stage: "error", message });
tokenRetryRef.current.markFailed();
@@ -632,7 +618,7 @@ const GuacamoleAppInner = React.forwardRef<
onClose={() => setFileBrowserOpen(false)}
/>
)}
{hostConfig.enableTerminalToolbar !== false && !toolbarHidden && (
{hostConfig.showToolbar !== false && !toolbarHidden && (
<GuacamoleToolbar
displayRef={displayRef}
protocol={resolvedProtocol}
@@ -1,4 +1,3 @@
import { getErrorMessage } from "@/lib/error-message.js";
import type React from "react";
import {
useEffect,
@@ -11,15 +10,18 @@ import {
import Guacamole from "guacamole-common-js";
import { Upload } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { isElectron } from "@/main-axios";
import { getGuacamoleToken } from "./guacamole-api";
import { getBasePath } from "@/lib/base-path.ts";
import { buildGuacamoleWebSocketBaseUrl } from "./guacamole-websocket-url.ts";
import {
resolveConnectionOrigin,
buildOriginWsUrl,
getBasePath,
guacStateToStage,
isElectron,
resolveConnectionOrigin,
type ConnectionOrigin,
} from "@/lib/connection-origin.ts";
type ConnectionStage,
} from "@termix/plugin-sdk/ui";
import { errorMessage } from "./host-remote";
import { isPasteShortcut, pasteTextToRemote } from "./guacamole-clipboard.ts";
import { getGuacamoleDisplaySize } from "./guacamole-display-size.ts";
import { bindPointerInput } from "./guacamole-pointer.ts";
@@ -31,8 +33,6 @@ import {
uploadFileToClient,
type GuacamoleFileStreamClient,
} from "./guacamole-filesystem.ts";
import { guacStateToStage } from "@/components/connection/connection-status.ts";
import type { ConnectionStage } from "@/types/connection-log.ts";
import { clampGuacamoleZoom, stepGuacamoleZoom } from "./guacamole-zoom.ts";
export type GuacamoleConnectionType = "rdp" | "vnc" | "telnet";
@@ -243,10 +243,10 @@ export const GuacamoleDisplay = forwardRef<
// Resolved before the token is minted, not just before the socket is
// opened: the token has to come from whichever backend will serve the
// session, so both steps have to agree on the origin.
const origin = await resolveConnectionOrigin({
connectionType: connectionProtocol,
connectionOrigin: connectionConfig.connectionOrigin,
});
const origin = await resolveConnectionOrigin(
{ connectionOrigin: connectionConfig.connectionOrigin },
{ defaultRemote: true },
);
if (connectionConfig.token) {
token = connectionConfig.token;
@@ -317,8 +317,8 @@ export const GuacamoleDisplay = forwardRef<
if (displaySize.dpi) params.set("dpi", String(displaySize.dpi));
return { url: wsBase, query: params.toString() };
} catch (error) {
const errorMessage = getErrorMessage(error);
onError?.(errorMessage);
const message = errorMessage(error);
onError?.(message);
return null;
}
},
@@ -698,7 +698,7 @@ export const GuacamoleDisplay = forwardRef<
if (!isMountedRef.current) return;
setIsReady(false);
setHasError(true);
onError?.(getErrorMessage(error, t("remoteDesktop.connectionError")));
onError?.(errorMessage(error, t("remoteDesktop.connectionError")));
}
}, [
getWebSocketConnection,
@@ -1,4 +1,4 @@
import { getErrorMessage } from "@/lib/error-message.js";
import { errorMessage } from "./host-remote";
import { useCallback, useEffect, useRef, useState } from "react";
import Guacamole from "guacamole-common-js";
import { useTranslation } from "@termix/plugin-sdk/frontend";
@@ -12,7 +12,7 @@ import {
X,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/button.tsx";
import { Button } from "@termix/plugin-sdk/ui";
import {
downloadFile,
listDirectory,
@@ -56,7 +56,7 @@ export function GuacamoleFileBrowser({
setEntries(await listDirectory(filesystem, target));
} catch (err) {
setEntries([]);
setError(getErrorMessage(err, t("remoteDesktop.files.listFailed")));
setError(errorMessage(err, t("remoteDesktop.files.listFailed")));
} finally {
setLoading(false);
}
@@ -26,14 +26,14 @@ import {
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/tooltip.tsx";
} from "@termix/plugin-sdk/ui";
import type {
GuacamoleDisplayHandle,
GuacamoleTouchMode,
} from "./GuacamoleDisplay.tsx";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { ActionSlot } from "@termix/plugin-sdk/ui";
import { cn } from "@/lib/utils";
import { cn } from "@termix/plugin-sdk/ui";
import {
detectRuntimeMetaKeyFamily,
metaKeyLabels,
@@ -1,8 +1,12 @@
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Input } from "@/components/input";
import { PasswordInput } from "@/components/password-input";
import { FakeSwitch, SectionCard, SettingRow } from "@/components/section-card";
import type { Host } from "@/types/ui-types";
import {
FakeSwitch,
Input,
PasswordInput,
SectionCard,
Select2,
SettingRow,
} from "@termix/plugin-sdk/ui";
import {
Activity,
Copy,
@@ -16,13 +20,10 @@ import {
Zap,
Cpu,
} from "lucide-react";
import type { HostEditorForm } from "@/sidebar/HostEditorData";
import { Select2 } from "@/components/select2";
import type { RemoteDesktopForm, RemoteFormSetField } from "./remote-form";
type HostEditorSetField = <K extends keyof HostEditorForm>(
key: K,
value: HostEditorForm[K],
) => void;
type HostEditorForm = RemoteDesktopForm;
type HostEditorSetField = RemoteFormSetField;
type GuacFieldSetter = (key: string, value: unknown) => void;
@@ -49,7 +50,7 @@ export function HostEditorRdpTab({
form: HostEditorForm;
setField: HostEditorSetField;
setGuacField: GuacFieldSetter;
host?: Host | null;
host?: { macAddress?: string | null } | null;
credentials?: { id: string; name: string; username: string }[];
}) {
const { t } = useTranslation();
@@ -984,7 +985,7 @@ export function HostEditorVncTab({
form: HostEditorForm;
setField: HostEditorSetField;
setGuacField: GuacFieldSetter;
host?: Host | null;
host?: { macAddress?: string | null } | null;
credentials?: { id: string; name: string; username: string }[];
}) {
const { t } = useTranslation();
@@ -1523,7 +1524,6 @@ export function HostEditorTelnetTab({
form: HostEditorForm;
setField: HostEditorSetField;
setGuacField: GuacFieldSetter;
host?: Host | null;
credentials?: { id: string; name: string; username: string }[];
}) {
const { t } = useTranslation();
@@ -1,13 +1,18 @@
import type { PluginApiClient, TermixApp } from "@termix/plugin-sdk/frontend";
import {
authApi,
getRemoteGuacamoleApi,
handleApiError,
isElectron,
} from "@/main-axios";
import type { AxiosInstance } from "axios";
import type { GuacamoleConfig } from "@/types/guacamole-config";
import { resolveRemoteHostId } from "@/lib/remote-server-api";
import type { ConnectionOrigin } from "@/lib/connection-origin";
resolveRemoteHostId,
type ConnectionOrigin,
} from "@termix/plugin-sdk/ui";
import type { GuacamoleConfig } from "./guacamole-config";
import { errorMessage } from "./host-remote";
let app: Pick<TermixApp, "apiFor"> | null = null;
/** Set in activate, cleared on deactivate. */
export function setRemoteDesktopApp(next: Pick<TermixApp, "apiFor"> | null) {
app = next;
}
/**
* Picks the backend that will actually serve this session, so the token and
@@ -24,9 +29,13 @@ import type { ConnectionOrigin } from "@/lib/connection-origin";
* silently sent a missed call site to the remote server, which fails with a
* bare network error on a desktop that has none configured.
*/
function guacamoleApi(origin: ConnectionOrigin): AxiosInstance {
if (!isElectron()) return authApi;
return origin === "local" ? authApi : getRemoteGuacamoleApi();
function guacamoleApi(origin: ConnectionOrigin): PluginApiClient {
if (!app) throw new Error("Remote Desktop is not active");
return app.apiFor(origin);
}
function apiError(error: unknown, action: string): Error {
return new Error(errorMessage(error, `Failed to ${action}`));
}
export interface GuacamoleTokenRequest {
@@ -51,18 +60,6 @@ type GuacamoleConfigSource = {
guacamoleConfig?: string | Record<string, unknown> | null;
};
export function parseGuacamoleConfig(
config?: string | GuacamoleConfig | null,
): GuacamoleConfig {
if (!config) return {};
if (typeof config !== "string") return config;
try {
return JSON.parse(config) as GuacamoleConfig;
} catch {
return {};
}
}
export function getGuacamoleDpi(
source?: GuacamoleConfigSource,
): number | undefined {
@@ -174,8 +171,8 @@ export async function getGuacamoleToken(
try {
const guacParams = toGuacamoleParams(request.guacamoleConfig);
const response = await guacamoleApi(origin).post(
"/plugin-api/remote-desktop/token",
const response = await guacamoleApi(origin).post<GuacamoleTokenResponse>(
"/token",
{
type: request.protocol,
hostname: request.hostname,
@@ -190,7 +187,7 @@ export async function getGuacamoleToken(
);
return response.data;
} catch (error) {
throw handleApiError(error, "get guacamole token");
throw apiError(error, "get a connection token");
}
}
@@ -204,6 +201,7 @@ export async function getGuacamoleTokenFromHost(
domain?: string;
},
syncId?: string | null,
tabInstanceId?: string,
): Promise<GuacamoleTokenResponse> {
try {
// A locally-originated session is served by the embedded backend, which
@@ -216,10 +214,11 @@ export async function getGuacamoleTokenFromHost(
throw new Error("The synced host does not exist on the remote server");
}
const targetHostId = remoteHostId ?? hostId;
const response = await guacamoleApi(origin).post(
`/plugin-api/remote-desktop/connect-host/${targetHostId}`,
const response = await guacamoleApi(origin).post<GuacamoleTokenResponse>(
`/connect-host/${targetHostId}`,
{
...(protocol ? { protocol } : {}),
...(tabInstanceId ? { tabInstanceId } : {}),
...(promptedCredentials?.username
? { promptedUsername: promptedCredentials.username }
: {}),
@@ -233,15 +232,22 @@ export async function getGuacamoleTokenFromHost(
);
return response.data;
} catch (error) {
throw handleApiError(error, "get guacamole token from host");
throw apiError(error, "get a connection token");
}
}
export async function getGuacdStatus(origin: ConnectionOrigin): Promise<{
export interface RemoteDesktopStatus {
enabled: boolean;
guacd: { status: string };
}> {
const response = await guacamoleApi(origin).get(
"/plugin-api/remote-desktop/status",
}
/** `probe: false` skips dialing guacd, for a caller that only needs `enabled`. */
export async function getGuacdStatus(
origin: ConnectionOrigin,
options: { probe?: boolean } = {},
): Promise<RemoteDesktopStatus> {
const response = await guacamoleApi(origin).get<RemoteDesktopStatus>(
options.probe === false ? "/status?probe=0" : "/status",
);
return response.data;
}
@@ -251,9 +257,34 @@ export async function getGuacamoleConnectionId(
origin: ConnectionOrigin,
signal?: AbortSignal,
): Promise<string | null> {
const response = await guacamoleApi(origin).get(
`/plugin-api/remote-desktop/connection/${encodeURIComponent(connectId)}`,
{ signal },
);
const response = await guacamoleApi(origin).get<{
guacamoleConnectionId?: string | null;
}>(`/connection/${encodeURIComponent(connectId)}`, { signal });
return response.data.guacamoleConnectionId ?? null;
}
export async function nativeRdpAvailable(): Promise<boolean> {
if (!isElectron()) return false;
try {
const response = await guacamoleApi("local").get<{ available: boolean }>(
"/native-rdp",
);
return response.data.available === true;
} catch {
return false;
}
}
/** Always the embedded backend: it is the one that can reach Electron. */
export async function openNativeRdp(request: {
host: string;
port?: number;
username?: string;
domain?: string;
}): Promise<{ success: boolean; error?: string }> {
const response = await guacamoleApi("local").post<{
success: boolean;
error?: string;
}>("/native-rdp", request);
return response.data;
}
@@ -0,0 +1,64 @@
/**
* Per-host Guacamole connection settings, as edited in the host editor and
* forwarded to guacd. Field names are camelCase here and translated to the
* hyphenated protocol parameters in toGuacamoleParams().
*/
export interface GuacamoleConfig {
colorDepth?: number;
width?: number;
height?: number;
dpi?: number;
resizeMethod?: string;
forceLossless?: boolean;
disableAudio?: boolean;
enableAudioInput?: boolean;
enableWallpaper?: boolean;
enableTheming?: boolean;
enableFontSmoothing?: boolean;
enableFullWindowDrag?: boolean;
enableDesktopComposition?: boolean;
enableMenuAnimations?: boolean;
disableBitmapCaching?: boolean;
disableOffscreenCaching?: boolean;
disableGlyphCaching?: boolean;
disableGfx?: boolean;
enablePrinting?: boolean;
printerName?: string;
enableDrive?: boolean;
driveName?: string;
drivePath?: string;
createDrivePath?: boolean;
disableDownload?: boolean;
disableUpload?: boolean;
enableTouch?: boolean;
clientName?: string;
console?: boolean;
initialProgram?: string;
serverLayout?: string;
timezone?: string;
gatewayHostname?: string;
gatewayPort?: number;
gatewayUsername?: string;
gatewayPassword?: string;
gatewayDomain?: string;
remoteApp?: string;
remoteAppDir?: string;
remoteAppArgs?: string;
normalizeClipboard?: string;
disableCopy?: boolean;
disablePaste?: boolean;
cursor?: string;
swapRedBlue?: boolean;
readOnly?: boolean;
recordingPath?: string;
recordingName?: string;
createRecordingPath?: boolean;
recordingExcludeOutput?: boolean;
recordingExcludeMouse?: boolean;
recordingIncludeKeys?: boolean;
wolSendPacket?: boolean;
wolMacAddr?: string;
wolBroadcastAddr?: string;
wolUdpPort?: number;
wolWaitTime?: number;
}
@@ -1,5 +1,5 @@
import { getGuacamoleConnectionId } from "./guacamole-api";
import type { ConnectionOrigin } from "@/lib/connection-origin";
import type { ConnectionOrigin } from "@termix/plugin-sdk/ui";
export function watchGuacamoleConnectionId(
connectId: string,
@@ -0,0 +1,139 @@
import type { PluginHostRecord } from "@termix/plugin-sdk/frontend";
import type { GuacamoleConfig } from "./guacamole-config";
export const PLUGIN_ID = "remote-desktop";
export type Protocol = "rdp" | "vnc" | "telnet";
/** This plugin's host settings, as they arrive on host.pluginSettings. */
export interface RemoteHostOptions {
enableRdp: boolean;
enableVnc: boolean;
enableTelnet: boolean;
rdpPort: number;
vncPort: number;
telnetPort: number;
rdpSecurity: string;
rdpIgnoreCert: boolean;
guacamoleConfig: GuacamoleConfig;
enableToolbar: boolean;
}
export const ENABLE_KEY = {
rdp: "enableRdp",
vnc: "enableVnc",
telnet: "enableTelnet",
} as const satisfies Record<Protocol, keyof RemoteHostOptions>;
export const PORT_KEY = {
rdp: "rdpPort",
vnc: "vncPort",
telnet: "telnetPort",
} as const satisfies Record<Protocol, keyof RemoteHostOptions>;
export const DEFAULT_PORT: Record<Protocol, number> = {
rdp: 3389,
vnc: 5900,
telnet: 23,
};
/** The core fields holding a protocol's login, which stay on the host. */
export interface RemoteHostLogin {
id?: number | string;
name?: string | null;
ip?: string;
domain?: string;
syncId?: string | null;
connectionOrigin?: "local" | "remote" | null;
rdpAuthType?: string;
rdpUser?: string;
rdpPassword?: string;
vncUser?: string;
vncPassword?: string;
authOverrides?: Record<
string,
{
credentialId?: number | string | null;
required?: boolean;
ownerAuthShared?: boolean;
}
>;
pluginSettings?: Record<string, Record<string, unknown>>;
}
function port(value: unknown, fallback: number): number {
const parsed = typeof value === "string" ? Number(value) : value;
return typeof parsed === "number" && Number.isInteger(parsed) && parsed > 0
? parsed
: fallback;
}
export function parseGuacamoleConfig(value: unknown): GuacamoleConfig {
if (!value) return {};
if (typeof value === "string") {
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}
return typeof value === "object" ? (value as GuacamoleConfig) : {};
}
export function remoteOptions(
settings: Record<string, unknown> | undefined,
): RemoteHostOptions {
const values = settings ?? {};
return {
enableRdp: values.enableRdp === true,
enableVnc: values.enableVnc === true,
enableTelnet: values.enableTelnet === true,
rdpPort: port(values.rdpPort, DEFAULT_PORT.rdp),
vncPort: port(values.vncPort, DEFAULT_PORT.vnc),
telnetPort: port(values.telnetPort, DEFAULT_PORT.telnet),
rdpSecurity:
typeof values.rdpSecurity === "string" ? values.rdpSecurity : "",
rdpIgnoreCert: values.rdpIgnoreCert === true,
guacamoleConfig: parseGuacamoleConfig(values.guacamoleConfig),
enableToolbar: values.enableToolbar !== false,
};
}
export function hostRemoteOptions(
host:
| Pick<RemoteHostLogin, "pluginSettings">
| PluginHostRecord
| null
| undefined,
): RemoteHostOptions {
const bag = (host as RemoteHostLogin | null | undefined)?.pluginSettings;
return remoteOptions(bag?.[PLUGIN_ID]);
}
export function protocolEnabled(
host:
| Pick<RemoteHostLogin, "pluginSettings">
| PluginHostRecord
| null
| undefined,
protocol: Protocol,
): boolean {
return hostRemoteOptions(host)[ENABLE_KEY[protocol]];
}
/** Quick Connect hosts are never saved; their ids carry this prefix. */
const QUICK_CONNECT_ID_PREFIX = "quick-connect-";
export function isQuickConnectHost(host: { id?: unknown } | null | undefined) {
return String(host?.id ?? "").startsWith(QUICK_CONNECT_ID_PREFIX);
}
export function errorMessage(error: unknown, fallback = "Unknown error") {
const data = (error as { response?: { data?: { error?: unknown } } })
?.response?.data?.error;
if (typeof data === "string" && data) return data;
if (error instanceof Error && error.message) return error.message;
if (typeof error === "string" && error) return error;
return fallback;
}
+172 -126
View File
@@ -1,23 +1,21 @@
import type { ComponentType, Ref } from "react";
import i18next from "i18next";
import { toast } from "sonner";
import {
Info,
MessagesSquare,
Monitor,
MonitorUp,
MousePointerClick,
Settings,
} from "lucide-react";
import {
useTranslation,
type HostEditorSectionProps,
type PluginHostRecord,
type StandaloneViewProps,
type TabProps,
type TermixApp,
} from "@termix/plugin-sdk/frontend";
import { FakeSwitch, SectionCard, SettingRow } from "@termix/plugin-sdk/ui";
import type { Host } from "@/types/ui-types";
import { isQuickConnectHost } from "@/sidebar/quick-connect-host";
import { SectionCard, isElectron } from "@termix/plugin-sdk/ui";
import { toast } from "sonner";
import GuacamoleApp, { type GuacamoleAppHandle } from "./GuacamoleApp";
import { GuacamoleDisplay } from "./GuacamoleDisplay";
import {
@@ -25,48 +23,70 @@ import {
HostEditorTelnetTab,
HostEditorVncTab,
} from "./HostEditorGuacamoleTabs";
import { getGuacamoleTokenFromHost } from "./guacamole-api";
import {
getGuacamoleTokenFromHost,
getGuacdStatus,
nativeRdpAvailable,
openNativeRdp,
setRemoteDesktopApp,
} from "./guacamole-api";
import { quickConnectGuacHost } from "./quick-connect-guac-host";
import { REMOTE_DESKTOP_TOOLBAR_SLOT } from "./GuacamoleToolbar.tsx";
type Protocol = "rdp" | "vnc" | "telnet";
import {
DEFAULT_PORT,
ENABLE_KEY,
PORT_KEY,
hostRemoteOptions,
isQuickConnectHost,
protocolEnabled,
type Protocol,
type RemoteHostLogin,
} from "./host-remote";
import { remoteDesktopForm } from "./remote-form";
const PROTOCOLS: {
id: Protocol;
titleKey: string;
descriptionKey: string;
paletteKey: string;
icon: ComponentType<{ className?: string }>;
enableKey: "enableRdp" | "enableVnc" | "enableTelnet";
priority: number;
order: number;
quickConnect?: { showDomain?: boolean };
}[] = [
{
id: "rdp",
titleKey: "hosts.tabRdp",
descriptionKey: "hosts.remoteDesktop",
paletteKey: "palette.connectRdp",
icon: Monitor,
enableKey: "enableRdp",
priority: 50,
order: 100,
quickConnect: { showDomain: true },
},
{
id: "vnc",
titleKey: "hosts.tabVnc",
descriptionKey: "hosts.virtualNetwork",
paletteKey: "palette.connectVnc",
icon: MousePointerClick,
enableKey: "enableVnc",
priority: 40,
order: 110,
quickConnect: {},
},
{
id: "telnet",
titleKey: "hosts.tabTelnet",
descriptionKey: "hosts.unencryptedShell",
paletteKey: "palette.connectTelnet",
icon: MessagesSquare,
enableKey: "enableTelnet",
priority: 30,
order: 120,
},
];
function RemoteDesktopTab({ tab, host, isVisible, handleRef }: TabProps) {
const shellHost = host as unknown as Host;
const record = host as unknown as RemoteHostLogin | undefined;
return (
<GuacamoleApp
ref={handleRef as Ref<GuacamoleAppHandle>}
@@ -75,8 +95,8 @@ function RemoteDesktopTab({ tab, host, isVisible, handleRef }: TabProps) {
protocol={tab.type as Protocol}
isVisible={isVisible}
quickConnectHost={
isQuickConnectHost(shellHost)
? quickConnectGuacHost(shellHost)
record && isQuickConnectHost(record)
? quickConnectGuacHost(record)
: undefined
}
/>
@@ -111,79 +131,65 @@ function RemoteDisplay({
);
}
/** Applies one remote desktop field and stops inheriting user defaults. */
function useGuacFieldSetter(
updateForm: HostEditorSectionProps["updateForm"],
): (key: string, value: unknown) => void {
return (key, value) =>
updateForm((current) => ({
inheritRemoteDesktopDefaults: false,
guacamoleConfig: {
...((current.guacamoleConfig as Record<string, unknown>) ?? {}),
[key]: value,
},
}));
function SectionNotes({ protocol }: { protocol: Protocol }) {
const { t } = useTranslation();
return (
<SectionCard
title={t("hosts.remoteDesktopNotes")}
icon={<Info className="size-3.5" />}
>
<div className="flex flex-col gap-2 py-3 text-xs text-muted-foreground">
{protocol === "rdp" && <p>{t("hosts.userDefaultsNote")}</p>}
{isElectron() && <p>{t("hosts.connectionOriginNote")}</p>}
</div>
</SectionCard>
);
}
function RdpSection(props: HostEditorSectionProps) {
const { t } = useTranslation();
const setGuacField = useGuacFieldSetter(props.updateForm);
const { form, setField, setGuacField } = remoteDesktopForm(props);
return (
<>
<SectionCard
title={t("hosts.rdpDefaults", { defaultValue: "RDP defaults" })}
icon={<Settings className="size-3.5" />}
>
<SettingRow
label={t("hosts.useUserDefaults", {
defaultValue: "Use user defaults",
})}
description={t("hosts.useUserRdpDefaultsDesc", {
defaultValue:
"Inherit performance, redirection, and clipboard settings from User Profile.",
})}
>
<FakeSwitch
checked={!!props.form.inheritRemoteDesktopDefaults}
onChange={(value) =>
props.setField("inheritRemoteDesktopDefaults", value)
}
/>
</SettingRow>
</SectionCard>
<HostEditorRdpTab
form={props.form}
setField={props.setField as never}
setGuacField={setGuacField as never}
host={props.host as unknown as Host}
form={form}
setField={setField}
setGuacField={setGuacField}
host={props.host as { macAddress?: string | null } | undefined}
credentials={props.credentials as never}
/>
<SectionNotes protocol="rdp" />
</>
);
}
function VncSection(props: HostEditorSectionProps) {
const setGuacField = useGuacFieldSetter(props.updateForm);
const { form, setField, setGuacField } = remoteDesktopForm(props);
return (
<HostEditorVncTab
form={props.form}
setField={props.setField as never}
setGuacField={setGuacField as never}
host={props.host as unknown as Host}
credentials={props.credentials as never}
/>
<>
<HostEditorVncTab
form={form}
setField={setField}
setGuacField={setGuacField}
host={props.host as { macAddress?: string | null } | undefined}
credentials={props.credentials as never}
/>
<SectionNotes protocol="vnc" />
</>
);
}
function TelnetSection(props: HostEditorSectionProps) {
const setGuacField = useGuacFieldSetter(props.updateForm);
const { form, setField, setGuacField } = remoteDesktopForm(props);
return (
<HostEditorTelnetTab
form={props.form}
setField={props.setField as never}
setGuacField={setGuacField as never}
credentials={props.credentials as never}
/>
<>
<HostEditorTelnetTab
form={form}
setField={setField}
setGuacField={setGuacField}
credentials={props.credentials as never}
/>
<SectionNotes protocol="telnet" />
</>
);
}
@@ -193,23 +199,96 @@ const SECTIONS: Record<Protocol, ComponentType<HostEditorSectionProps>> = {
telnet: TelnetSection,
};
async function openNativeRdp(host: Record<string, unknown>): Promise<void> {
const t = (key: string) => i18next.t(`remote-desktop:${key}`);
try {
const result = await window.electronAPI.openNativeRdp({
host: String(host.ip),
port: (host.rdpPort as number | undefined) ?? 3389,
username: host.rdpUser as string | undefined,
domain: host.domain as string | undefined,
function registerNativeRdp(app: TermixApp): void {
let disposed = false;
app.onDispose(() => {
disposed = true;
});
void nativeRdpAvailable().then((available) => {
if (disposed || !available) return;
app.registerHostAction({
id: "rdp-native",
titleKey: "hosts.openNativeRdp",
icon: MonitorUp,
kind: "open",
order: 105,
when: (host) => protocolEnabled(host, "rdp"),
run: (host) => {
const record = host as unknown as RemoteHostLogin;
void openNativeRdp({
host: String(record.ip ?? ""),
port: hostRemoteOptions(record).rdpPort,
username: record.rdpUser,
domain: record.domain,
})
.then((result) => {
if (result.success) toast.success(app.t("hosts.nativeRdpOpened"));
else toast.error(result.error || app.t("hosts.nativeRdpFailed"));
})
.catch(() => toast.error(app.t("hosts.nativeRdpFailed")));
},
});
});
}
function registerHostSurfaces(app: TermixApp): void {
for (const protocol of PROTOCOLS) {
const when = (host: PluginHostRecord) => protocolEnabled(host, protocol.id);
app.registerHostProtocol({
id: protocol.id,
settingKey: ENABLE_KEY[protocol.id],
portKey: PORT_KEY[protocol.id],
defaultPort: DEFAULT_PORT[protocol.id],
titleKey: protocol.titleKey,
descriptionKey: protocol.descriptionKey,
icon: protocol.icon,
order: protocol.order,
quickConnect: protocol.quickConnect,
});
app.registerHostAction({
id: protocol.id,
titleKey: protocol.titleKey,
icon: protocol.icon,
kind: "connect",
priority: protocol.priority,
order: protocol.order,
tabType: protocol.id,
copyUrlView: protocol.id,
when,
});
app.registerPaletteEntry({
id: `remote-desktop.${protocol.id}`,
titleKey: protocol.paletteKey,
icon: protocol.icon,
keywords: [protocol.id],
scope: "host",
when: (host) => !!host && when(host),
run: (shell, host) => {
if (host) shell.openTab(host, protocol.id);
},
});
app.registerHostEditorSection({
id: protocol.id,
group: "top",
titleKey: protocol.titleKey,
icon: protocol.icon,
order: protocol.order / 5,
visible: (protocols) => !!protocols[ENABLE_KEY[protocol.id]],
component: SECTIONS[protocol.id],
});
if (result.success) toast.success(t("hosts.nativeRdpOpened"));
else toast.error(result.error || t("hosts.nativeRdpFailed"));
} catch {
toast.error(t("hosts.nativeRdpFailed"));
}
if (isElectron()) registerNativeRdp(app);
}
export function activate(app: TermixApp): void {
setRemoteDesktopApp(app);
app.onDispose(() => setRemoteDesktopApp(null));
// Guest pages only draw shared streams.
app.registerSlotContribution("session.remoteDisplay", {
actionId: "remote-desktop.display",
@@ -253,57 +332,24 @@ export function activate(app: TermixApp): void {
noHostMessageKey: "remoteDesktop.noHostSelected",
persistent: true,
session: true,
restore: (host) => !!host[protocol.enableKey],
restore: (host) => protocolEnabled(host, protocol.id),
activityTypes: [protocol.id],
standalone: RemoteDesktopStandalone,
preload: () => import("./GuacamoleApp"),
});
app.registerHostAction({
id: protocol.id,
titleKey: protocol.titleKey,
icon: protocol.icon,
kind: "connect",
priority: protocol.priority,
order: protocol.order,
tabType: protocol.id,
copyUrlView: protocol.id,
when: (host) => !!host[protocol.enableKey],
});
app.registerHostEditorSection({
id: protocol.id,
group: "top",
titleKey: protocol.titleKey,
icon: protocol.icon,
order: protocol.order / 5,
visible: (protocols) => !!protocols[protocol.enableKey],
component: SECTIONS[protocol.id],
});
}
// The desktop app on Windows can hand RDP to the system client instead.
if (window.electronAPI?.isElectron) {
let disposed = false;
app.onDispose(() => {
disposed = true;
// An admin can turn Remote Desktop off; its ways in go with it.
let disposed = false;
app.onDispose(() => {
disposed = true;
});
void getGuacdStatus("local", { probe: false })
.then((status) => status.enabled !== false)
.catch(() => true)
.then((enabled) => {
if (!disposed && enabled) registerHostSurfaces(app);
});
window.electronAPI
.getPlatform()
.then((platform: string) => {
if (disposed || platform !== "win32") return;
app.registerHostAction({
id: "rdp-native",
titleKey: "hosts.openNativeRdp",
icon: MonitorUp,
kind: "open",
order: 105,
when: (host) => !!host.enableRdp,
run: (host) => void openNativeRdp(host),
});
})
.catch(() => {});
}
app.registerSlotContribution("onboarding.features", {
actionId: "remote-desktop.feature",
@@ -1,19 +1,24 @@
import type { Host } from "@/types/ui-types";
import type { GuacamoleQuickHost } from "./GuacamoleApp";
import {
hostRemoteOptions,
type Protocol,
type RemoteHostLogin,
} from "./host-remote";
/** The slice of a quick-connect host that GuacamoleApp mints a token from. */
export function quickConnectGuacHost(host: Host): GuacamoleQuickHost {
export function quickConnectGuacHost(
host: RemoteHostLogin,
): GuacamoleQuickHost {
const options = hostRemoteOptions(host);
const connectionType: Protocol = options.enableVnc ? "vnc" : "rdp";
return {
name: host.name,
ip: host.ip,
connectionType: host.enableVnc ? "vnc" : "rdp",
name: host.name ?? undefined,
ip: host.ip ?? "",
connectionType,
domain: host.domain,
rdpPort: host.rdpPort,
vncPort: host.vncPort,
port: connectionType === "vnc" ? options.vncPort : options.rdpPort,
rdpAuthType: host.rdpAuthType,
rdpUser: host.rdpUser,
rdpPassword: host.rdpPassword,
vncUser: host.vncUser,
vncPassword: host.vncPassword,
username: connectionType === "vnc" ? host.vncUser : host.rdpUser,
password: connectionType === "vnc" ? host.vncPassword : host.rdpPassword,
};
}
@@ -1,4 +1,4 @@
import type { HostAuthOverrides } from "@/types/auth-protocols";
import type { RemoteHostLogin } from "./host-remote";
export function needsRdpCredentialPrompt({
protocol,
@@ -7,7 +7,7 @@ export function needsRdpCredentialPrompt({
}: {
protocol: "rdp" | "vnc" | "telnet";
rdpAuthType?: string;
authOverrides?: HostAuthOverrides;
authOverrides?: RemoteHostLogin["authOverrides"];
}): boolean {
return (
protocol === "rdp" &&
@@ -0,0 +1,100 @@
import type { HostEditorSectionProps } from "@termix/plugin-sdk/frontend";
import { PLUGIN_ID, remoteOptions } from "./host-remote";
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* The host editor fields the RDP, VNC and Telnet tabs edit. The logins are
* core host fields; the options (ports, security, guacd settings) are this
* plugin's host settings, presented here under the names the tabs use.
*/
export interface RemoteDesktopForm {
domain: string;
security: string;
ignoreCert: boolean;
rdpPort: number;
vncPort: number;
telnetPort: number;
guacamoleConfig: Record<string, any>;
rdpAuthType: "direct" | "credential" | "none";
rdpCredentialId: string;
rdpUser: string;
rdpPassword: string;
vncAuthType: "direct" | "credential";
vncCredentialId: string;
vncUser: string;
vncPassword: string;
telnetAuthType: "direct" | "credential";
telnetCredentialId: string;
telnetUser: string;
telnetPassword: string;
statsConfig: Record<string, any>;
}
export type RemoteFormSetField = <K extends keyof RemoteDesktopForm>(
key: K,
value: RemoteDesktopForm[K],
) => void;
/** Tab field name to this plugin's host setting key. */
const SETTING_FOR_FIELD: Partial<Record<keyof RemoteDesktopForm, string>> = {
rdpPort: "rdpPort",
vncPort: "vncPort",
telnetPort: "telnetPort",
security: "rdpSecurity",
ignoreCert: "rdpIgnoreCert",
};
type PluginSettingsBag = Record<string, Record<string, unknown>>;
function patchSettings(
current: Record<string, unknown>,
patch: (settings: Record<string, unknown>) => Record<string, unknown>,
): Record<string, unknown> {
const bag = (current.pluginSettings as PluginSettingsBag | undefined) ?? {};
const mine = bag[PLUGIN_ID] ?? {};
return {
pluginSettings: { ...bag, [PLUGIN_ID]: { ...mine, ...patch(mine) } },
};
}
export function remoteDesktopForm(props: HostEditorSectionProps): {
form: RemoteDesktopForm;
setField: RemoteFormSetField;
setGuacField: (key: string, value: unknown) => void;
} {
const bag = props.form?.pluginSettings as PluginSettingsBag | undefined;
const options = remoteOptions(bag?.[PLUGIN_ID]);
const form: RemoteDesktopForm = {
...(props.form as RemoteDesktopForm),
rdpPort: options.rdpPort,
vncPort: options.vncPort,
telnetPort: options.telnetPort,
security: options.rdpSecurity,
ignoreCert: options.rdpIgnoreCert,
guacamoleConfig: options.guacamoleConfig as Record<string, any>,
};
const setField: RemoteFormSetField = (key, value) => {
const setting = SETTING_FOR_FIELD[key];
if (!setting) {
props.setField(key, value);
return;
}
props.updateForm((current) =>
patchSettings(current, () => ({ [setting]: value })),
);
};
const setGuacField = (key: string, value: unknown) =>
props.updateForm((current) =>
patchSettings(current, (mine) => ({
guacamoleConfig: {
...remoteOptions(mine).guacamoleConfig,
[key]: value,
},
})),
);
return { form, setField, setGuacField };
}