feat: move remote desktop (RDP/VNC/Telnet) into a first-party plugin

Relocates guacamole backend/frontend source and tests into
plugins/remote-desktop, following the ssh-terminal/docker pattern.
This commit is contained in:
LukeGus committed 2026-09-21 20:25:52 -05:00
1 parent d440ce9073
commit c6bcfdb80d
50 files changed
+1031 -232

No files matched your search

+68
View File
@@ -0,0 +1,68 @@
# Remote Desktop
RDP, VNC and Telnet remote desktop sessions, packaged as a first-party plugin.
## Why this plugin runs in-process
Every other plugin runs in a `worker_threads` worker and reaches the server
through a structured-clone `postMessage` boundary. That boundary is what makes
the capability gate and the audit trail mean anything: a worker plugin cannot
hold a db handle, an ssh2 `Client` or a live socket, so every privileged action
has to go through the broker.
This plugin cannot work that way:
- it owns a `guacamole-lite` WebSocket server on port 30008,
- its `/connect-host/:hostId` route does multi-step credential and tunnel
resolution (SSH jump-host tunnels, a macOS VNC compatibility proxy) using
raw `net.Socket` plumbing,
- it needs plaintext host credentials to connect at all.
None of that can cross a structured-clone boundary, so this plugin runs on the
main thread instead, under the `process:transport-owner` tier, the same as
`ssh-terminal`, `docker` and `host-metrics`.
## guacd is not spawned by this plugin
guacd (the Apache Guacamole proxy daemon that actually speaks RDP/VNC/Telnet)
is a separate, external service reached over plain TCP -- by default a
sibling Docker container, or any network-reachable host configured through the
admin "guacd URL" setting. This plugin does not download, bundle or spawn
guacd as a child process.
That is a deliberate limitation, not an oversight: Apache Guacamole does not
publish prebuilt guacd binaries for any platform (only source tarballs and
their own Docker image), and there is no viable native guacd for Windows at
all. Bundling guacd for Linux and macOS is possible in principle but would
make Termix the ongoing upstream builder and security patcher for guacd plus
FreeRDP/libvncclient/libssh2/libtelnet -- out of scope for this conversion.
guacd's availability is handled as an external dependency with graceful
degradation, exactly as it was before this plugin existed.
## What lives where
Unlike `ssh-terminal` (whose implementation stays in core because
`session-manager.ts` has consumers well beyond the terminal), this plugin's
backend and frontend code is fully relocated here from
`src/backend/hosts/guacamole/` and `src/ui/features/guacamole/`, the same
shape `docker` and `host-metrics` use.
Two pieces of shared infrastructure still reach across the plugin boundary
into this plugin rather than living in core:
- `src/backend/hosts/collab/routes.ts` and
`src/backend/hosts/session-sharing/` import `token-service.ts` and
`guacamole-server.ts` directly to resolve and validate live Guacamole
sessions for collaborative/shared viewing.
- `src/ui/features/collab/` and `src/ui/features/session-sharing/` import
`GuacamoleDisplay.tsx` directly for the same reason.
This is an accepted, temporary coupling: collab and session-sharing are
expected to become plugins of their own later, at which point these become
real inter-plugin references instead of raw relative imports.
## Disabling
Disabling behaves like any other plugin: the rdp/vnc/telnet tabs stop being
offered from the host list, command palette and quick connect, the
`/guacamole` router 404s, and the WebSocket server on port 30008 is closed.
@@ -0,0 +1,29 @@
export const GUACD_DRIVE_PATH_ENV = "GUACD_DRIVE_PATH";
const DEFAULT_DRIVE_ROOT = "/drive";
/**
* Fills in where guacd keeps the files behind RDP drive redirection.
*
* The folder lives on the guacd host, not on Termix's - with the stock
* compose it is a directory in the shared termix-data volume, configured
* through GUACD_DRIVE_PATH. Each user gets a folder of their own underneath:
* a shared drive would show everyone's uploads to everyone else. A host that
* names its own drive-path keeps it.
*/
export function withDriveSettings(
guacConfig: Record<string, unknown>,
userId: string,
env: NodeJS.ProcessEnv = process.env,
): Record<string, unknown> {
if (!guacConfig["enable-drive"] || guacConfig["drive-path"]) {
return guacConfig;
}
const root = (
env[GUACD_DRIVE_PATH_ENV]?.trim() || DEFAULT_DRIVE_ROOT
).replace(/\/+$/, "");
return {
...guacConfig,
"drive-path": `${root}/${userId}`,
"create-drive-path": true,
};
}
@@ -0,0 +1,289 @@
import GuacamoleLite from "guacamole-lite";
import { guacLogger } from "../../../src/backend/utils/logger.js";
import {
GuacamoleTokenService,
type GuacamoleRecordingMetadata,
} from "./token-service.js";
import {
createCurrentSessionRecordingRepository,
getCurrentSettingValue,
} from "../../../src/backend/database/repositories/factory.js";
import { resolveGuacdOptions } from "../../../src/backend/utils/guacd-config.js";
import fs from "fs";
import path from "path";
const tokenService = GuacamoleTokenService.getInstance();
function readGuacdOptions(): { host: string; port: number } {
let dbUrl: string | undefined;
try {
dbUrl = getCurrentSettingValue("guac_url") ?? undefined;
} catch {
// DB not available yet, use env var defaults
}
return resolveGuacdOptions(dbUrl);
}
export const GUAC_WS_PORT = 30008;
const DATA_DIR = process.env.DATA_DIR || "./db/data";
const GUACAMOLE_RECORDINGS_DIR =
process.env.GUACD_RECORDING_BACKEND_PATH ||
path.join(DATA_DIR, "session_recordings", "guacamole");
type GuacamoleClientConnection = {
guacamoleConnectionId?: string;
connectionSettings?: {
connection?: { type?: string; join?: string; readOnly?: boolean };
recording?: GuacamoleRecordingMetadata;
termixMeta?: {
termixConnectId: string;
hostId: number;
ownerUserId: string;
protocol: string;
};
};
};
export interface GuacSessionInfo {
guacamoleConnectionId: string;
hostId: number;
ownerUserId: string;
protocol: string;
openedAt: number;
}
// Keyed by termixConnectId (routes.ts's correlation id), populated once the
// primary connection's guacd handshake completes.
const guacSessionByConnectId = new Map<string, GuacSessionInfo>();
// Keyed by guacd's own guacamoleConnectionId, for join-time lookups.
const guacSessionByGuacamoleId = new Map<string, GuacSessionInfo>();
export function getGuacSessionByConnectId(
connectId: string,
userId: string,
): GuacSessionInfo | null {
const info = guacSessionByConnectId.get(connectId);
return info?.ownerUserId === userId ? info : null;
}
export function getGuacSessionInfo(
guacamoleConnectionId: string,
): GuacSessionInfo | null {
return guacSessionByGuacamoleId.get(guacamoleConnectionId) ?? null;
}
async function persistGuacamoleRecording(
clientConnection: GuacamoleClientConnection,
): Promise<void> {
const recording = clientConnection.connectionSettings?.recording;
if (!recording) return;
const resolvedPath = path.resolve(GUACAMOLE_RECORDINGS_DIR, recording.path);
const allowedBase = `${path.resolve(GUACAMOLE_RECORDINGS_DIR)}${path.sep}`;
if (!resolvedPath.startsWith(allowedBase)) return;
// guacd may flush/rename the recording just after the websocket closes.
for (
let attempt = 0;
attempt < 10 && !fs.existsSync(resolvedPath);
attempt++
) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
if (!fs.existsSync(resolvedPath)) {
const guacdPath = recording.guacdPath ?? GUACAMOLE_RECORDINGS_DIR;
guacLogger.warn("Guacamole recording file was not found", {
operation: "guac_recording_missing",
hostId: recording.hostId,
path: resolvedPath,
guacdPath,
hint:
"guacd writes the recording to guacdPath, the backend reads it from path. " +
"When guacd runs in its own container these must be the same volume — set " +
"GUACD_RECORDING_PATH to guacd's mount point and GUACD_RECORDING_BACKEND_PATH to this one.",
});
return;
}
const endedAt = new Date();
const startedAt = new Date(recording.startedAt);
await createCurrentSessionRecordingRepository().create({
hostId: recording.hostId,
userId: recording.userId,
startedAt: startedAt.toISOString(),
endedAt: endedAt.toISOString(),
duration: Math.max(
0,
Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000),
),
recordingPath: resolvedPath,
protocol: recording.protocol,
format: "guacamole",
});
}
const websocketOptions = {
host: "127.0.0.1",
port: GUAC_WS_PORT,
};
const clientOptions = {
crypt: {
cypher: "AES-256-CBC",
key: tokenService.getEncryptionKey(),
},
log: {
level: "ERRORS",
stdLog: (...args: unknown[]) => {
guacLogger.info(args.join(" "));
},
errorLog: (...args: unknown[]) => {
guacLogger.error(args.join(" "));
},
},
allowedUnencryptedConnectionSettings: {
rdp: ["width", "height", "dpi"],
vnc: ["width", "height"],
telnet: ["width", "height"],
},
connectionDefaultSettings: {
rdp: {
security: "any",
"ignore-cert": true,
"enable-wallpaper": false,
"enable-font-smoothing": true,
"enable-desktop-composition": false,
"disable-audio": false,
"enable-drive": false,
"resize-method": "display-update",
width: 1280,
height: 720,
dpi: 96,
audio: ["audio/L16"],
},
vnc: {
"swap-red-blue": false,
cursor: "remote",
width: 1280,
height: 720,
// macOS Screen Sharing negotiates its VNC security type over several
// round trips (RFB type 30 -> 33/36/2/35) and can fail the first
// attempt; retrying lets guacd's VNC client re-establish instead of
// guacd giving up immediately (Support#1063).
autoretry: 2,
},
telnet: {
"terminal-type": "xterm-256color",
},
},
};
const _origConsoleLog = console.log;
console.log = (...args: unknown[]) => {
const msg = args[0];
if (typeof msg === "string" && msg.startsWith("New client connection"))
return;
_origConsoleLog(...args);
};
function createGuacServer(): GuacamoleLite {
const guacdOptions = readGuacdOptions();
const server = new GuacamoleLite(
websocketOptions,
guacdOptions,
clientOptions,
);
server.on("open", (clientConnection: GuacamoleClientConnection) => {
guacLogger.info("Guacamole connection opened", {
operation: "guac_connection_open",
type: clientConnection.connectionSettings?.connection?.type,
});
const termixMeta = clientConnection.connectionSettings?.termixMeta;
const guacamoleConnectionId = clientConnection.guacamoleConnectionId;
const isJoin = !!clientConnection.connectionSettings?.connection?.join;
if (!isJoin && termixMeta && guacamoleConnectionId) {
const info: GuacSessionInfo = {
guacamoleConnectionId,
hostId: termixMeta.hostId,
ownerUserId: termixMeta.ownerUserId,
protocol: termixMeta.protocol,
openedAt: Date.now(),
};
guacSessionByConnectId.set(termixMeta.termixConnectId, info);
guacSessionByGuacamoleId.set(guacamoleConnectionId, info);
}
});
server.on("close", (clientConnection: GuacamoleClientConnection) => {
guacLogger.info("Guacamole connection closed", {
operation: "guac_connection_close",
type: clientConnection.connectionSettings?.connection?.type,
});
const isJoin = !!clientConnection.connectionSettings?.connection?.join;
const termixMeta = clientConnection.connectionSettings?.termixMeta;
const guacamoleConnectionId = clientConnection.guacamoleConnectionId;
if (!isJoin && termixMeta && guacamoleConnectionId) {
guacSessionByConnectId.delete(termixMeta.termixConnectId);
guacSessionByGuacamoleId.delete(guacamoleConnectionId);
}
persistGuacamoleRecording(clientConnection).catch((error) => {
guacLogger.error("Failed to persist Guacamole recording", error, {
operation: "guac_recording_persist_error",
});
});
});
server.on(
"error",
(clientConnection: GuacamoleClientConnection, error: Error) => {
guacLogger.error("Guacamole connection error", error, {
operation: "guac_connection_error",
type: clientConnection.connectionSettings?.connection?.type,
});
},
);
return server;
}
let guacServer: GuacamoleLite | null = null;
/**
* Starts the guacamole-lite WebSocket server. Called from this plugin's
* activate() -- see plugins/remote-desktop/backend/index.mjs.
*/
export async function startGuacamoleService(): Promise<void> {
guacServer = createGuacServer();
}
/**
* Stops the guacamole-lite WebSocket server. Called from this plugin's
* deactivate().
*/
export async function stopGuacamoleService(): Promise<void> {
if (!guacServer) return;
try {
guacServer.close();
} catch (err) {
guacLogger.error("Error closing guac server during shutdown", err as Error);
}
guacServer = null;
}
export async function restartGuacServer(): Promise<void> {
// A no-op while the plugin is disabled: creating a new server here would
// silently resurrect the WS server behind deactivate()'s back.
if (!guacServer) return;
try {
guacServer.close();
} catch (err) {
guacLogger.error("Error closing guac server during restart", err as Error);
}
guacServer = createGuacServer();
}
export { guacServer, tokenService };
+74
View File
@@ -0,0 +1,74 @@
/**
* Remote Desktop (RDP/VNC/Telnet) - first-party, in-process plugin.
*
* Runs on the main thread rather than in a worker for the same class of
* reason as ssh-terminal/docker (see src/backend/plugins/first-party.ts): it
* owns a WebSocket server (guacamole-lite) on port 30008, and its
* /connect-host/:hostId route does multi-step credential/tunnel resolution
* (jump-host SSH tunnels, a macOS VNC compatibility proxy) involving raw
* net.Socket plumbing the worker's structured-clone postMessage boundary
* cannot carry.
*
* guacd itself is not a process this plugin spawns: it is a separate
* external service (a sibling Docker container by default, or any
* network-reachable guacd) reached over plain TCP. See guacd-config.ts's
* resolveGuacdOptions(). There is nothing to supervise here - guacd's
* availability is handled as an external dependency with graceful
* degradation, same as before this plugin existed.
*
* Its backend logic is real TypeScript physically relocated here (routes.ts,
* guacamole-server.ts, token-service.ts, etc.), compiled by
* scripts/copy-bundled-plugins.cjs like docker's plugin backend, rather than
* kept as a thin wrapper over code left in core -- see that script's own
* comment for why. The one piece that stays in core is
* src/backend/database/routes/guacamole-dispatch.ts, since it is what
* database.ts mounts permanently at /guacamole regardless of whether this
* plugin is installed.
*/
import path from "node:path";
import { fileURLToPath } from "node:url";
async function loadRelative(relativePath) {
const here = path.dirname(fileURLToPath(import.meta.url));
const target = path.join(here, `${relativePath}.js`);
return import(new URL(`file://${target.replace(/\\/g, "/")}`).href);
}
let guacServerModule = null;
let dispatchModule = null;
export async function activate(ctx) {
const [server, routes, dispatch] = await Promise.all([
loadRelative("guacamole-server"),
loadRelative("routes"),
loadRelative("../../../backend/backend/database/routes/guacamole-dispatch"),
]);
guacServerModule = server;
dispatchModule = dispatch;
await server.startGuacamoleService();
dispatch.registerGuacamoleBridge({
router: routes.router,
restart: server.restartGuacServer,
createJoinToken: (guacamoleConnectionId, readOnly) =>
server.tokenService.createJoinToken(guacamoleConnectionId, readOnly),
getSessionInfo: server.getGuacSessionInfo,
});
ctx.log.info(
`Remote Desktop listening on ${server.GUAC_WS_PORT ?? 30008}`,
);
}
export async function deactivate() {
if (dispatchModule) {
dispatchModule.unregisterGuacamoleBridge();
dispatchModule = null;
}
if (guacServerModule) {
await guacServerModule.stopGuacamoleService();
guacServerModule = null;
}
}
@@ -0,0 +1,15 @@
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
export function resolveJumpTunnelEndpoint(
guacdHost: string,
tunnelHost = process.env.GUACD_TUNNEL_HOST,
): { bindHost: string; advertisedHost: string } {
if (LOOPBACK_HOSTS.has(guacdHost.toLowerCase())) {
return { bindHost: "127.0.0.1", advertisedHost: "127.0.0.1" };
}
return {
bindHost: "0.0.0.0",
advertisedHost: tunnelHost?.trim() || "termix",
};
}
@@ -0,0 +1,97 @@
import net from "net";
const RFB_BANNER_LENGTH = 12;
const VNC_AUTH_SECURITY_TYPE = 2;
const MACOS_RFB_BANNER = Buffer.from("RFB 003.889\n", "ascii");
const STANDARD_RFB_BANNER = Buffer.from("RFB 003.008\n", "ascii");
export interface VncCompatibilityProxy {
port: number;
close: () => void;
}
export async function createMacosVncCompatibilityProxy({
targetHost,
targetPort,
bindHost,
}: {
targetHost: string;
targetPort: number;
bindHost: string;
}): Promise<VncCompatibilityProxy> {
const sockets = new Set<net.Socket>();
const server = net.createServer((client) => {
const upstream = net.createConnection(targetPort, targetHost);
sockets.add(client);
sockets.add(upstream);
const forget = (socket: net.Socket) => sockets.delete(socket);
client.once("close", () => forget(client));
upstream.once("close", () => forget(upstream));
client.once("error", () => upstream.destroy());
upstream.once("error", () => client.destroy());
client.pipe(upstream);
let pending = Buffer.alloc(0);
const forwardMacosSecurityTypes = (chunk: Buffer) => {
pending = Buffer.concat([pending, chunk]);
if (pending.length < 1) return;
const securityTypeCount = pending[0];
if (securityTypeCount === 0) {
upstream.off("data", forwardMacosSecurityTypes);
client.write(pending);
upstream.pipe(client);
return;
}
if (pending.length < 1 + securityTypeCount) return;
upstream.off("data", forwardMacosSecurityTypes);
const securityTypes = pending.subarray(1, 1 + securityTypeCount);
if (securityTypes.includes(VNC_AUTH_SECURITY_TYPE)) {
client.write(Buffer.from([1, VNC_AUTH_SECURITY_TYPE]));
} else {
client.write(pending.subarray(0, 1 + securityTypeCount));
}
client.write(pending.subarray(1 + securityTypeCount));
upstream.pipe(client);
};
const forwardServerBanner = (chunk: Buffer) => {
pending = Buffer.concat([pending, chunk]);
if (pending.length < RFB_BANNER_LENGTH) return;
upstream.off("data", forwardServerBanner);
const banner = pending.subarray(0, RFB_BANNER_LENGTH);
const remainder = pending.subarray(RFB_BANNER_LENGTH);
if (banner.equals(MACOS_RFB_BANNER)) {
client.write(STANDARD_RFB_BANNER);
pending = Buffer.alloc(0);
upstream.on("data", forwardMacosSecurityTypes);
if (remainder.length > 0) forwardMacosSecurityTypes(remainder);
} else {
client.write(banner);
client.write(remainder);
upstream.pipe(client);
}
};
upstream.on("data", forwardServerBanner);
});
const port = await new Promise<number>((resolve, reject) => {
server.once("error", reject);
server.listen(0, bindHost, () => {
server.off("error", reject);
resolve((server.address() as net.AddressInfo).port);
});
});
server.unref();
return {
port,
close: () => {
server.close();
for (const socket of sockets) socket.destroy();
},
};
}
@@ -0,0 +1,64 @@
interface RdpSettingsInput {
port: number;
domain?: string;
security?: string;
ignoreCert: boolean;
guacConfig: Record<string, unknown>;
guacdOverrides: Record<string, unknown>;
}
export function buildRdpSettings({
port,
domain,
security,
ignoreCert,
guacConfig,
guacdOverrides,
}: RdpSettingsInput): Record<string, unknown> {
return {
...guacConfig,
port,
domain,
...(security === undefined ? {} : { security }),
"ignore-cert": ignoreCert,
...guacdOverrides,
};
}
export function resolveRdpDomain(
authType: string | null,
promptedDomain: unknown,
storedDomain: string,
): string {
return authType === "none" && typeof promptedDomain === "string"
? promptedDomain
: storedDomain;
}
type SharedRdpAuthResolution =
| { source: "personal-override" }
| { source: "owner-shared"; authType: string }
| { source: "secretless" }
| { source: "required" }
| null;
export function resolveRdpAuthTypeForConnect({
storedAuthType,
credentialId,
sharedResolution,
}: {
storedAuthType?: string | null;
credentialId?: number | null;
sharedResolution?: SharedRdpAuthResolution;
}): string {
if (sharedResolution === undefined) {
return storedAuthType || (credentialId ? "credential" : "direct");
}
if (sharedResolution?.source === "personal-override") return "credential";
if (sharedResolution?.source === "owner-shared") {
return sharedResolution.authType;
}
return sharedResolution?.source === "secretless" && storedAuthType === "none"
? "none"
: "direct";
}
@@ -0,0 +1,22 @@
/**
* Merges Termix's recording bookkeeping into a host's guacd settings.
*
* Location and filename are not the host's to choose: recordings are indexed by
* them for playback, and the backend refuses to read anything outside its
* recordings directory. What a recording *contains* is a host-level decision, so
* those flags are only defaulted, never overwritten.
*/
export function withRecordingSettings(
guacConfig: Record<string, unknown>,
recordingPath: string,
recordingName: string,
): Record<string, unknown> {
return {
...guacConfig,
"recording-path": recordingPath,
"recording-name": recordingName,
"create-recording-path": true,
"recording-exclude-output": guacConfig["recording-exclude-output"] ?? false,
"recording-include-keys": guacConfig["recording-include-keys"] ?? true,
};
}
+831
View File
@@ -0,0 +1,831 @@
import { getErrorMessage } from "../../../src/backend/utils/error-message.js";
import express from "express";
import { GuacamoleTokenService } from "./token-service.js";
import { withRecordingSettings } from "./recording-settings.js";
import { withDriveSettings } from "./drive-settings.js";
import { guacLogger } from "../../../src/backend/utils/logger.js";
import { AuthManager } from "../../../src/backend/utils/auth-manager.js";
import { PermissionManager } from "../../../src/backend/utils/permission-manager.js";
import {
resolveRecipientSharedHostAuthentication,
type RecipientSharedHostAuthResolution,
} from "../../../src/backend/utils/shared-host-auth-resolver.js";
import type { AuthOverrideProtocol } from "../../../src/types/auth-protocols.js";
import net from "net";
import crypto from "crypto";
import path from "path";
import type { AuthenticatedRequest } from "../../../src/types/index.js";
import {
createCurrentHostResolutionRepository,
createCurrentSettingsRepository,
} from "../../../src/backend/database/repositories/factory.js";
import { resolveGuacdOptions } from "../../../src/backend/utils/guacd-config.js";
import { createJumpHostChain } from "../../../src/backend/hosts/jump-host-chain.js";
import { getGuacSessionByConnectId } from "./guacamole-server.js";
import {
logAudit,
getAuditUsername,
getRequestMeta,
} from "../../../src/backend/utils/audit-logger.js";
import { resolveJumpTunnelEndpoint } from "./jump-tunnel-endpoint.js";
import {
buildRdpSettings,
resolveRdpAuthTypeForConnect,
resolveRdpDomain,
} from "./rdp-settings.js";
import { createMacosVncCompatibilityProxy } from "./macos-vnc-proxy.js";
const router = express.Router();
const tokenService = GuacamoleTokenService.getInstance();
const authManager = AuthManager.getInstance();
const DATA_DIR = process.env.DATA_DIR || "./db/data";
router.use(authManager.createAuthMiddleware());
router.get("/connection/:connectId", (req: AuthenticatedRequest, res) => {
if (!req.userId)
return res.status(401).json({ error: "Authentication required" });
const session = getGuacSessionByConnectId(
String(req.params.connectId),
req.userId,
);
res.json({ guacamoleConnectionId: session?.guacamoleConnectionId ?? null });
});
/**
* @openapi
* /guacamole/token:
* post:
* summary: Generate an encrypted Guacamole connection token
* description: Creates an AES-256-CBC encrypted token for guacamole-lite with the given connection parameters
* tags:
* - Guacamole
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - type
* - hostname
* properties:
* type:
* type: string
* enum: [rdp, vnc, telnet]
* hostname:
* type: string
* port:
* type: integer
* username:
* type: string
* password:
* type: string
* domain:
* type: string
* responses:
* 200:
* description: Encrypted connection token
* content:
* application/json:
* schema:
* type: object
* properties:
* token:
* type: string
* 400:
* description: Invalid request
* 500:
* description: Server error
*/
router.post("/token", async (req, res) => {
try {
const { type, hostname, port, username, password, domain, ...rawOptions } =
req.body;
// Strip "auto" sentinel values before forwarding to guacd
const options: Record<string, unknown> = {};
for (const [key, value] of Object.entries(rawOptions)) {
if (value !== "auto") options[key] = value;
}
if (!type || !hostname) {
return res
.status(400)
.json({ error: "Missing required fields: type and hostname" });
}
if (!["rdp", "vnc", "telnet"].includes(type)) {
return res.status(400).json({
error: "Invalid connection type. Must be rdp, vnc, or telnet",
});
}
let token: string;
switch (type) {
case "rdp":
token = tokenService.createRdpToken(
hostname,
username || "",
password || "",
{
port: port || 3389,
domain,
...options,
},
);
break;
case "vnc":
token = tokenService.createVncToken(
hostname,
username || undefined,
password,
{
port: port || 5900,
...options,
},
);
break;
case "telnet":
token = tokenService.createTelnetToken(hostname, username, password, {
port: port || 23,
...options,
});
break;
default:
return res.status(400).json({ error: "Invalid connection type" });
}
res.json({ token });
} catch (error) {
guacLogger.error("Failed to generate guacamole token", error, {
operation: "guac_token_error",
});
res.status(500).json({ error: "Failed to generate connection token" });
}
});
/**
* @openapi
* /guacamole/connect-host/{hostId}:
* post:
* summary: Generate Guacamole connection token from host configuration
* description: Fetches host configuration from database and generates a connection token for RDP/VNC/Telnet
* tags:
* - Guacamole
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: hostId
* required: true
* schema:
* type: integer
* description: Host ID to connect to
* requestBody:
* required: false
* content:
* application/json:
* schema:
* type: object
* properties:
* protocol:
* type: string
* enum: [rdp, vnc, telnet]
* description: Override the host's default connection type
* promptedUsername:
* type: string
* description: Username for this connection only, used when the host's RDP auth type is "none". Not persisted.
* promptedPassword:
* type: string
* description: Password for this connection only, used when the host's RDP auth type is "none". Not persisted.
* responses:
* 200:
* description: Connection token generated successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* token:
* type: string
* description: Encrypted connection token
* guacamoleConnectionId:
* type: string
* nullable: true
* description: Null until the WebSocket handshake completes. Query /guacamole/connection/{connectId} for the session-sharing ID.
* termixConnectId:
* type: string
* description: Correlation ID for looking up this connection after the WebSocket opens.
* 400:
* description: Invalid request or unsupported connection type
* 403:
* description: Access denied to host
* 404:
* description: Host not found
* 500:
* description: Server error
*/
router.post(
"/connect-host/:hostId",
async (req: express.Request, res: express.Response) => {
try {
const userId = (req as AuthenticatedRequest).userId!;
const hostId = Number.parseInt(String(req.params.hostId), 10);
if (!hostId || isNaN(hostId)) {
return res.status(400).json({ error: "Invalid host ID" });
}
const hostResolutionRepository = createCurrentHostResolutionRepository();
// Decrypt under the owner's DEK; shared hosts carry owner-encrypted fields.
const hostOwnerId =
await hostResolutionRepository.findHostOwnerId(hostId);
const host = hostOwnerId
? await hostResolutionRepository.findHostById(hostId, hostOwnerId)
: null;
if (!host) {
return res.status(404).json({ error: "Host not found" });
}
if (host.userId !== userId) {
const permissionManager = PermissionManager.getInstance();
const accessInfo = await permissionManager.canAccessHost(
userId,
hostId,
"connect",
);
if (!accessInfo.hasAccess) {
guacLogger.warn("User attempted to access host without permission", {
operation: "guac_access_denied",
userId,
hostId,
});
return res.status(403).json({ error: "Access denied to this host" });
}
}
const requestedProtocol = req.body?.protocol as string | undefined;
const connectionType =
requestedProtocol || (host.connectionType as string);
if (!["rdp", "vnc", "telnet"].includes(connectionType)) {
return res.status(400).json({
error: `Connection type '${connectionType}' is not supported for remote desktop. Only RDP, VNC, and Telnet are supported.`,
});
}
// Old hosts only had connectionType set; enableRdp/enableVnc/enableTelnet defaulted to false.
// Apply the same migration fallback used in host.ts GET routes.
const ct = host.connectionType as string;
const rdpRaw = !!host.enableRdp;
const vncRaw = !!host.enableVnc;
const telRaw = !!host.enableTelnet;
const isMigratedNonSsh =
!rdpRaw && !vncRaw && !telRaw && ct && ct !== "ssh";
const protocolEnabledMap: Record<string, boolean> = {
rdp: isMigratedNonSsh ? ct === "rdp" : rdpRaw,
vnc: isMigratedNonSsh ? ct === "vnc" : vncRaw,
telnet: isMigratedNonSsh ? ct === "telnet" : telRaw,
};
if (!protocolEnabledMap[connectionType]) {
return res.status(400).json({
error: `${connectionType.toUpperCase()} is not enabled for this host.`,
});
}
let guacConfig: Record<string, unknown> = {};
if (host.guacamoleConfig) {
try {
guacConfig =
typeof host.guacamoleConfig === "string"
? JSON.parse(host.guacamoleConfig as string)
: (host.guacamoleConfig as Record<string, unknown>);
} catch (error) {
guacLogger.warn("Failed to parse guacamole config", {
operation: "guac_config_parse_error",
hostId,
error: getErrorMessage(error),
});
}
}
// Strip "auto" sentinel values — these mean "use guacd default" in the UI
// but guacd doesn't recognise "auto" as a valid parameter value.
for (const key of Object.keys(guacConfig)) {
if (guacConfig[key] === "auto") {
delete guacConfig[key];
}
}
// Extract per-connection guacd proxy settings before passing the rest as connection settings
const perConnectionGuacdHost = guacConfig["guacd-hostname"] as
string | undefined;
const perConnectionGuacdPortRaw = guacConfig["guacd-port"];
const perConnectionGuacdPort = perConnectionGuacdPortRaw
? parseInt(String(perConnectionGuacdPortRaw), 10) || undefined
: undefined;
delete guacConfig["guacd-hostname"];
delete guacConfig["guacd-port"];
if (guacConfig.dpi != null) {
const parsed = parseInt(String(guacConfig.dpi), 10);
guacConfig.dpi =
Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
}
const hostRecord = host as Record<string, unknown>;
const hostRepository = hostResolutionRepository;
const isSharedConnection = host.userId !== userId;
let sharedAuthResolution: RecipientSharedHostAuthResolution | null = null;
if (isSharedConnection) {
// Recipients never read the owner's raw secrets; wipe them and use
// the per-recipient snapshot for the requested protocol instead.
host.password = null;
host.rdpUser = null;
host.rdpPassword = null;
host.vncUser = null;
host.vncPassword = null;
host.telnetUser = null;
host.telnetPassword = null;
try {
const resolution = await resolveRecipientSharedHostAuthentication(
host,
hostId,
userId,
connectionType as AuthOverrideProtocol,
);
sharedAuthResolution = resolution;
const auth =
resolution.source === "personal-override"
? { ...resolution.credential, domain: null }
: resolution.source === "owner-shared"
? resolution.secret
: null;
if (auth) {
if (connectionType === "rdp") {
host.rdpUser = auth.username ?? null;
host.rdpPassword = auth.password ?? null;
if (auth.domain) host.rdpDomain = auth.domain;
} else if (connectionType === "vnc") {
host.vncUser = auth.username ?? null;
host.vncPassword = auth.password ?? null;
} else if (connectionType === "telnet") {
host.telnetUser = auth.username ?? null;
host.telnetPassword = auth.password ?? null;
}
}
} catch (e) {
guacLogger.warn("Failed to resolve shared host auth", {
operation: "guac_shared_auth_resolve",
hostId,
protocol: connectionType,
error: getErrorMessage(e, "Unknown"),
});
}
} else {
// Backward compat: if authType is not stored but a credentialId is, treat as credential mode
const rdpEffectiveAuthType =
(host.rdpAuthType as string) ||
(host.rdpCredentialId ? "credential" : "direct");
const vncEffectiveAuthType =
(host.vncAuthType as string) ||
(host.vncCredentialId ? "credential" : "direct");
const telnetEffectiveAuthType =
(host.telnetAuthType as string) ||
(hostRecord.telnetCredentialId ? "credential" : "direct");
if (rdpEffectiveAuthType === "credential" && host.rdpCredentialId) {
try {
const cred = await hostRepository.findCredentialByIdForUser(
host.rdpCredentialId as number,
host.userId as string,
);
if (cred) {
if (cred.username) host.rdpUser = cred.username;
if (cred.password) host.rdpPassword = cred.password;
// domain is never sourced from credential
}
} catch (e) {
guacLogger.warn("Failed to resolve RDP credential", {
operation: "guac_rdp_credential_resolve",
hostId,
error: getErrorMessage(e, "Unknown"),
});
}
}
if (vncEffectiveAuthType === "credential" && host.vncCredentialId) {
try {
const cred = await hostRepository.findCredentialByIdForUser(
host.vncCredentialId as number,
host.userId as string,
);
if (cred) {
if (cred.password) host.vncPassword = cred.password;
if (cred.username) host.vncUser = cred.username;
}
} catch (e) {
guacLogger.warn("Failed to resolve VNC credential", {
operation: "guac_vnc_credential_resolve",
hostId,
error: getErrorMessage(e, "Unknown"),
});
}
}
if (
telnetEffectiveAuthType === "credential" &&
hostRecord.telnetCredentialId
) {
try {
const cred = await hostRepository.findCredentialByIdForUser(
hostRecord.telnetCredentialId as number,
host.userId as string,
);
if (cred) {
if (cred.username) host.telnetUser = cred.username;
if (cred.password) host.telnetPassword = cred.password;
}
} catch (e) {
guacLogger.warn("Failed to resolve Telnet credential", {
operation: "guac_telnet_credential_resolve",
hostId,
error: getErrorMessage(e, "Unknown"),
});
}
}
}
let token: string;
let hostname = host.ip as string;
let port = host.port as number;
let username: string;
let password: string;
const rdpAuthTypeForConnect = resolveRdpAuthTypeForConnect({
storedAuthType: host.rdpAuthType as string | null,
credentialId: host.rdpCredentialId as number | null,
sharedResolution: isSharedConnection ? sharedAuthResolution : undefined,
});
switch (connectionType) {
case "rdp":
if (rdpAuthTypeForConnect === "none") {
username = String(req.body?.promptedUsername || "");
password = String(req.body?.promptedPassword || "");
} else {
username =
(host.rdpUser as string) || (host.username as string) || "";
password =
(host.rdpPassword as string) || (host.password as string) || "";
}
port = (host.rdpPort as number) || port || 3389;
break;
case "vnc":
username = (host.vncUser as string) || "";
password =
(host.vncPassword as string) || (host.password as string) || "";
port = (host.vncPort as number) || port || 5900;
break;
case "telnet":
username = (host.telnetUser as string) || "";
password =
(host.telnetPassword as string) || (host.password as string) || "";
port = (host.telnetPort as number) || port || 23;
break;
default:
username = "";
password = "";
}
const storedDomain =
(host.rdpDomain as string) || (host.domain as string) || "";
const domain = resolveRdpDomain(
rdpAuthTypeForConnect,
req.body?.promptedDomain,
storedDomain,
);
// Establish SSH tunnel if jump hosts are configured
let jumpHosts: Array<{ hostId: number }> = [];
if (host.jumpHosts) {
try {
jumpHosts =
typeof host.jumpHosts === "string"
? JSON.parse(host.jumpHosts as string)
: (host.jumpHosts as Array<{ hostId: number }>);
} catch {
jumpHosts = [];
}
}
let tunnelEndpoint: ReturnType<typeof resolveJumpTunnelEndpoint> | null =
null;
if (jumpHosts.length > 0 || (connectionType === "vnc" && !username)) {
let guacdUrl: string | undefined;
try {
guacdUrl =
(await createCurrentSettingsRepository().get("guac_url")) ??
undefined;
} catch {
// Environment/default guacd configuration remains available.
}
const guacdHost =
perConnectionGuacdHost || resolveGuacdOptions(guacdUrl).host;
tunnelEndpoint = resolveJumpTunnelEndpoint(guacdHost);
}
if (jumpHosts.length > 0) {
try {
// The chain dials the first hop through that hop's own SOCKS5
// settings; the target host's proxy config does not apply to it.
const jumpClient = await createJumpHostChain(jumpHosts, userId);
if (!jumpClient) {
guacLogger.error(
"Failed to establish jump host chain for guacamole",
undefined,
{ operation: "guac_ssh_tunnel_error", hostId },
);
return res.status(500).json({
error: "Failed to establish SSH tunnel to remote host",
});
}
const targetHostname = hostname;
const targetPort = port;
const tunnelPort = await new Promise<number>((resolve, reject) => {
const server = net.createServer((sock) => {
jumpClient.forwardOut(
"127.0.0.1",
0,
targetHostname,
targetPort,
(err, stream) => {
if (err) {
sock.destroy();
return;
}
sock.pipe(stream).pipe(sock);
},
);
});
server.on("error", reject);
server.listen(0, tunnelEndpoint!.bindHost, () => {
const addr = server.address() as net.AddressInfo;
// Auto-cleanup after 1 hour
setTimeout(
() => {
server.close();
jumpClient.end();
},
60 * 60 * 1000,
);
resolve(addr.port);
});
});
hostname = tunnelEndpoint!.advertisedHost;
port = tunnelPort;
guacLogger.info("SSH tunnel established for guacamole", {
operation: "guac_ssh_tunnel",
hostId,
tunnelPort,
});
} catch (tunnelError) {
guacLogger.error("Failed to establish SSH tunnel", tunnelError, {
operation: "guac_ssh_tunnel_error",
hostId,
});
return res.status(500).json({
error: "Failed to establish SSH tunnel to remote host",
});
}
}
if (connectionType === "vnc" && !username) {
try {
const proxy = await createMacosVncCompatibilityProxy({
targetHost: hostname,
targetPort: port,
bindHost: tunnelEndpoint!.bindHost,
});
hostname = tunnelEndpoint!.advertisedHost;
port = proxy.port;
setTimeout(proxy.close, 60 * 60 * 1000).unref();
guacLogger.info("VNC compatibility proxy established", {
operation: "guac_vnc_compatibility_proxy",
hostId,
proxyPort: port,
});
} catch (proxyError) {
guacLogger.error(
"Failed to establish VNC compatibility proxy",
proxyError,
{
operation: "guac_vnc_compatibility_proxy_error",
hostId,
},
);
return res.status(500).json({
error: "Failed to establish VNC compatibility proxy",
});
}
}
const guacdOverrides = {
...(perConnectionGuacdHost
? { guacdHost: perConnectionGuacdHost }
: {}),
...(perConnectionGuacdPort
? { guacdPort: perConnectionGuacdPort }
: {}),
};
const recordingEnabled =
connectionType !== "vnc" && host.enableSessionLogging !== false;
const recordingName = `${crypto.randomUUID()}.guac`;
const recordingPath =
process.env.GUACD_RECORDING_PATH ||
process.env.GUACD_RECORDING_BACKEND_PATH ||
path.resolve(DATA_DIR, "session_recordings", "guacamole");
const recordingMetadata = recordingEnabled
? {
hostId,
userId,
protocol: connectionType as "rdp" | "vnc" | "telnet",
path: recordingName,
guacdPath: recordingPath,
startedAt: new Date().toISOString(),
}
: undefined;
if (recordingEnabled) {
guacConfig = withRecordingSettings(
guacConfig,
recordingPath,
recordingName,
);
}
const termixConnectId = crypto.randomUUID();
const termixMeta = {
termixConnectId,
hostId,
ownerUserId: userId,
protocol: connectionType as "rdp" | "vnc" | "telnet",
};
switch (connectionType) {
case "rdp":
guacConfig = withDriveSettings(guacConfig, userId);
token = tokenService.createRdpToken(
hostname,
username,
password,
buildRdpSettings({
port,
domain,
security:
(host.rdpSecurity as string) ||
(host.security as string) ||
undefined,
ignoreCert:
host.rdpIgnoreCert !== undefined
? !!host.rdpIgnoreCert
: host.ignoreCert !== undefined
? !!host.ignoreCert
: true,
guacConfig,
guacdOverrides,
}),
recordingMetadata,
termixMeta,
);
break;
case "vnc":
token = tokenService.createVncToken(
hostname,
username || undefined,
password,
{
port,
...guacConfig,
...guacdOverrides,
},
recordingMetadata,
termixMeta,
);
break;
case "telnet":
token = tokenService.createTelnetToken(
hostname,
username,
password,
{
port,
...guacConfig,
...guacdOverrides,
},
recordingMetadata,
termixMeta,
);
break;
default:
return res.status(400).json({ error: "Invalid connection type" });
}
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getAuditUsername(userId),
action: `${connectionType}_connect`,
resourceType: "host",
resourceId: String(hostId),
resourceName: `${hostname}:${port}`,
ipAddress,
userAgent,
success: true,
});
res.json({
token,
termixConnectId,
guacamoleConnectionId: null,
});
} catch (error) {
guacLogger.error("Failed to generate guacamole token for host", error, {
operation: "guac_host_token_error",
});
res.status(500).json({ error: "Failed to generate connection token" });
}
},
);
/**
* GET /guacamole/status
* Check if guacd is reachable
*/
router.get("/status", async (req, res) => {
try {
let dbUrl: string | undefined;
try {
dbUrl =
(await createCurrentSettingsRepository().get("guac_url")) ?? undefined;
} catch {
// Fall back to env vars
}
const { host: guacdHost, port: guacdPort } = resolveGuacdOptions(dbUrl);
const net = await import("net");
const checkConnection = (): Promise<boolean> => {
return new Promise((resolve) => {
const socket = new net.Socket();
socket.setTimeout(3000);
socket.on("connect", () => {
socket.destroy();
resolve(true);
});
socket.on("timeout", () => {
socket.destroy();
resolve(false);
});
socket.on("error", () => {
socket.destroy();
resolve(false);
});
socket.connect(guacdPort, guacdHost);
});
};
const isConnected = await checkConnection();
res.json({
guacd: {
host: guacdHost,
port: guacdPort,
status: isConnected ? "connected" : "disconnected",
},
websocket: {
port: 30008,
status: "running",
},
});
} catch (error) {
guacLogger.error("Failed to check guacamole status", error, {
operation: "guac_status_error",
});
res.status(500).json({ error: "Failed to check status" });
}
});
export { router };
@@ -0,0 +1,251 @@
import crypto from "crypto";
import { guacLogger } from "../../../src/backend/utils/logger.js";
export interface GuacamoleConnectionSettings {
type?: "rdp" | "vnc" | "telnet";
join?: string;
readOnly?: boolean;
guacdHost?: string;
guacdPort?: number;
settings: {
hostname?: string;
port?: number;
username?: string;
password?: string;
domain?: string;
width?: number;
height?: number;
dpi?: number;
security?: string;
"ignore-cert"?: boolean;
"disable-auth"?: boolean;
"enable-wallpaper"?: boolean;
"enable-drive"?: boolean;
"drive-path"?: string;
"create-drive-path"?: boolean;
"swap-red-blue"?: boolean;
cursor?: string;
"terminal-type"?: string;
[key: string]: unknown;
};
}
export interface TermixGuacMeta {
termixConnectId: string;
hostId: number;
ownerUserId: string;
protocol: "rdp" | "vnc" | "telnet";
}
export interface GuacamoleToken {
connection: GuacamoleConnectionSettings;
recording?: GuacamoleRecordingMetadata;
termixMeta?: TermixGuacMeta;
}
export interface GuacamoleRecordingMetadata {
hostId: number;
userId: string;
protocol: "rdp" | "vnc" | "telnet";
path: string;
/** Directory guacd was told to write into; differs from the backend's view
* when guacd runs in its own container. */
guacdPath?: string;
startedAt: string;
}
const CIPHER = "aes-256-cbc";
const KEY_LENGTH = 32;
export class GuacamoleTokenService {
private static instance: GuacamoleTokenService;
private encryptionKey: Buffer;
private constructor() {
this.encryptionKey = this.initializeKey();
}
static getInstance(): GuacamoleTokenService {
if (!GuacamoleTokenService.instance) {
GuacamoleTokenService.instance = new GuacamoleTokenService();
}
return GuacamoleTokenService.instance;
}
private initializeKey(): Buffer {
const existingKey = process.env.GUACAMOLE_ENCRYPTION_KEY;
if (existingKey) {
if (existingKey.length === 64 && /^[0-9a-fA-F]+$/.test(existingKey)) {
return Buffer.from(existingKey, "hex");
}
if (existingKey.length === KEY_LENGTH) {
return Buffer.from(existingKey, "utf8");
}
}
const jwtSecret = process.env.JWT_SECRET;
if (jwtSecret) {
return crypto
.createHash("sha256")
.update(jwtSecret + "_guacamole")
.digest();
}
guacLogger.warn(
"No persistent encryption key found, generating random key",
{
operation: "guac_key_generation",
},
);
return crypto.randomBytes(KEY_LENGTH);
}
getEncryptionKey(): Buffer {
return this.encryptionKey;
}
encryptToken(tokenObject: GuacamoleToken): string {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(CIPHER, this.encryptionKey, iv);
let encrypted = cipher.update(
JSON.stringify(tokenObject),
"utf8",
"base64",
);
encrypted += cipher.final("base64");
const data = {
iv: iv.toString("base64"),
value: encrypted,
};
return Buffer.from(JSON.stringify(data)).toString("base64");
}
decryptToken(token: string): GuacamoleToken | null {
try {
const data = JSON.parse(Buffer.from(token, "base64").toString("utf8"));
const iv = Buffer.from(data.iv, "base64");
const decipher = crypto.createDecipheriv(CIPHER, this.encryptionKey, iv);
let decrypted = decipher.update(data.value, "base64", "utf8");
decrypted += decipher.final("utf8");
return JSON.parse(decrypted) as GuacamoleToken;
} catch (error) {
guacLogger.error("Failed to decrypt guacamole token", error, {
operation: "guac_token_decrypt_error",
});
return null;
}
}
createRdpToken(
hostname: string,
username: string,
password: string,
options: Partial<GuacamoleConnectionSettings["settings"]> & {
guacdHost?: string;
guacdPort?: number;
} = {},
recording?: GuacamoleRecordingMetadata,
termixMeta?: TermixGuacMeta,
): string {
const { guacdHost, guacdPort, ...settingsOptions } = options;
const token: GuacamoleToken = {
connection: {
type: "rdp",
...(guacdHost ? { guacdHost } : {}),
...(guacdPort ? { guacdPort } : {}),
settings: {
hostname,
...(username ? { username } : {}),
...(password ? { password } : {}),
port: 3389,
"ignore-cert": true,
...(!username && !password ? { "disable-auth": true } : {}),
...settingsOptions,
},
},
recording,
termixMeta,
};
return this.encryptToken(token);
}
createVncToken(
hostname: string,
username?: string,
password?: string,
options: Partial<GuacamoleConnectionSettings["settings"]> & {
guacdHost?: string;
guacdPort?: number;
} = {},
recording?: GuacamoleRecordingMetadata,
termixMeta?: TermixGuacMeta,
): string {
const { guacdHost, guacdPort, ...settingsOptions } = options;
const token: GuacamoleToken = {
connection: {
type: "vnc",
...(guacdHost ? { guacdHost } : {}),
...(guacdPort ? { guacdPort } : {}),
settings: {
hostname,
...(username ? { username } : {}),
password,
port: 5900,
...settingsOptions,
},
},
recording,
termixMeta,
};
return this.encryptToken(token);
}
createTelnetToken(
hostname: string,
username?: string,
password?: string,
options: Partial<GuacamoleConnectionSettings["settings"]> & {
guacdHost?: string;
guacdPort?: number;
} = {},
recording?: GuacamoleRecordingMetadata,
termixMeta?: TermixGuacMeta,
): string {
const { guacdHost, guacdPort, ...settingsOptions } = options;
const token: GuacamoleToken = {
connection: {
type: "telnet",
...(guacdHost ? { guacdHost } : {}),
...(guacdPort ? { guacdPort } : {}),
settings: {
hostname,
username,
password,
port: 23,
...settingsOptions,
},
},
recording,
termixMeta,
};
return this.encryptToken(token);
}
// join tokens never carry recording params - only the primary connection's
// token should write recording-path/recording-name to guacd.
createJoinToken(guacamoleConnectionId: string, readOnly: boolean): string {
const token: GuacamoleToken = {
connection: {
join: guacamoleConnectionId,
readOnly,
settings: {},
},
};
return this.encryptToken(token);
}
}
@@ -0,0 +1,674 @@
import { watchGuacamoleConnectionId } from "./guacamole-session-id";
import { getErrorMessage } from "@/lib/error-message.js";
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,
getSSHHosts,
logActivity,
isElectron,
} from "@/main-axios.ts";
import { readConfiguredDimension } from "./guacamole-display-size.ts";
import { getGuacamoleToken, parseGuacamoleConfig } from "./guacamole-api";
import {
resolveConnectionOrigin,
type ConnectionOrigin,
} from "@/lib/connection-origin.ts";
import { useTranslation } from "react-i18next";
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 {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/dialog.tsx";
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
import {
ConnectionLogProvider,
useConnectionLog,
} from "@/ssh/connection-log/ConnectionLogContext.tsx";
import { useConnectionRetry } from "@/lib/useConnectionRetry.ts";
import { ShareSessionModal } from "@/features/session-sharing/ShareSessionModal.tsx";
import type { SSHHost } from "@/types";
import { useConnectionDefaults } from "@/contexts/ConnectionDefaultsContext";
import { resolveConnectionDefaults } from "@/lib/connection-defaults";
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 GuacamoleApp needs from a host that has no database row. */
export type GuacamoleQuickHost = GuacamoleAppInnerProps["hostConfig"] & {
name?: string;
};
export interface GuacamoleAppHandle {
disconnect: () => void;
isConnected: () => boolean;
openShareModal: () => void;
canShare: () => boolean;
}
const GuacamoleApp = React.forwardRef<GuacamoleAppHandle, GuacamoleAppProps>(
function GuacamoleApp(
{ hostId, tabId, protocol, isVisible = true, quickConnectHost },
ref,
) {
const { t } = useTranslation();
const defaults = useConnectionDefaults();
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;
}
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]);
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: Pick<
SSHHost,
| "connectionType"
| "domain"
| "guacamoleConfig"
| "rdpAuthType"
| "authOverrides"
| "syncId"
| "connectionOrigin"
| "ip"
| "rdpPort"
| "vncPort"
| "rdpUser"
| "rdpPassword"
| "vncUser"
| "vncPassword"
> & { enableTerminalToolbar?: boolean };
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 [shareModalOpen, setShareModalOpen] = useState(false);
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 = parseGuacamoleConfig(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 ?? "");
useImperativeHandle(ref, () => ({
disconnect: () => displayRef.current?.disconnect(),
isConnected: () => displayRef.current?.isConnected() === true,
openShareModal: () => setShareModalOpen(true),
canShare: () => guacamoleConnectionId !== null,
}));
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({
connectionType: resolvedProtocolForConnect,
connectionOrigin: hostConfig.connectionOrigin,
});
if (resolvedOrigin === "remote") {
const remoteConfig = (await window.electronAPI?.invoke?.(
"get-remote-sync-config",
)) as { serverUrl?: string } | null;
if (!remoteConfig?.serverUrl) {
throw new Error(t("errors.remoteServerRequired"));
}
}
}
addLog({
type: "info",
stage: "guac_guacd",
message: t("remoteDesktop.checkingGuacd"),
});
const status = await getGuacdStatus(resolvedOrigin);
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:
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),
},
resolvedOrigin,
)
: await getGuacamoleTokenFromHost(
hostId,
resolvedOrigin,
protocol,
promptedCredentials ?? undefined,
hostConfig.syncId,
);
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,
addLog,
t,
]);
const tokenRetry = useConnectionRetry({
connect: async () => {
try {
await fetchToken();
tokenRetryRef.current.markConnected();
} catch (err: unknown) {
const message = getErrorMessage(
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]);
useEffect(() => {
if (!tabId) return;
const handler = (e: Event) => {
const { tabId: eventTabId } = (e as CustomEvent).detail;
if (eventTabId === tabId) handleReconnect();
};
window.addEventListener("termix:refresh-guacamole", handler);
return () =>
window.removeEventListener("termix:refresh-guacamole", handler);
}, [tabId, 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.enableTerminalToolbar !== 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)}
/>
)}
{shareModalOpen && guacamoleConnectionId && (
<ShareSessionModal
open={shareModalOpen}
onClose={() => setShareModalOpen(false)}
hostId={hostId}
sessionId={guacamoleConnectionId}
protocol={resolvedProtocol}
tabInstanceId={tabId}
/>
)}
</div>
);
});
export default GuacamoleApp;
@@ -0,0 +1,967 @@
import { getErrorMessage } from "@/lib/error-message.js";
import type React from "react";
import {
useEffect,
useRef,
useState,
useImperativeHandle,
forwardRef,
useCallback,
} from "react";
import Guacamole from "guacamole-common-js";
import { Upload } from "lucide-react";
import { useTranslation } from "react-i18next";
import { getGuacamoleToken, isElectron } from "@/main-axios.ts";
import { getBasePath } from "@/lib/base-path.ts";
import { buildGuacamoleWebSocketBaseUrl } from "./guacamole-websocket-url.ts";
import {
resolveConnectionOrigin,
buildOriginWsUrl,
type ConnectionOrigin,
} from "@/lib/connection-origin.ts";
import { isPasteShortcut, pasteTextToRemote } from "./guacamole-clipboard.ts";
import { getGuacamoleDisplaySize } from "./guacamole-display-size.ts";
import { bindPointerInput } from "./guacamole-pointer.ts";
import {
getFileDropDisposition,
hasDraggedFiles,
} from "./guacamole-file-drop.ts";
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";
export interface GuacamoleConnectionConfig {
token?: string;
protocol?: GuacamoleConnectionType;
type?: GuacamoleConnectionType;
hostname?: string;
port?: number;
username?: string;
password?: string;
domain?: string;
width?: number;
height?: number;
dpi?: number;
/**
* Which backend serves this session. Supplied by the owning app so the
* websocket dials the same backend that minted the token; omitted by
* shared/collab views, which follow the default for the connection type.
*/
connectionOrigin?: ConnectionOrigin | null;
[key: string]: unknown;
}
export interface GuacamoleDisplayHandle {
disconnect: () => void;
isConnected: () => boolean;
sendKey: (keysym: number, pressed: boolean) => void;
sendMouse: (x: number, y: number, buttonMask: number) => void;
setClipboard: (data: string) => void;
getFilesystem: () => Guacamole.Object | null;
uploadFile: (file: File) => Promise<void>;
zoomIn: () => number;
zoomOut: () => number;
resetZoom: () => number;
}
export type GuacamoleTouchMode = "touchscreen" | "touchpad";
interface GuacamoleDisplayProps {
connectionConfig: GuacamoleConnectionConfig;
isVisible: boolean;
touchMode?: GuacamoleTouchMode | null;
allowUpload?: boolean;
onConnect?: () => void;
onDisconnect?: () => void;
onError?: (error: string) => void;
onFilesystem?: (filesystem: Guacamole.Object | null) => void;
onDropFiles?: (files: File[]) => void;
onDropUnavailable?: () => void;
onStageChange?: (stage: ConnectionStage) => void;
onZoomChange?: (zoom: number) => void;
}
const isDev = import.meta.env.DEV;
export const GuacamoleDisplay = forwardRef<
GuacamoleDisplayHandle,
GuacamoleDisplayProps
>(function GuacamoleDisplay(
{
connectionConfig,
isVisible,
touchMode,
allowUpload = false,
onConnect,
onDisconnect,
onError,
onFilesystem,
onDropFiles,
onDropUnavailable,
onStageChange,
onZoomChange,
},
ref,
) {
const { t } = useTranslation();
// The host config pins the session resolution; without it the display follows
// the container.
const hasConfiguredSize =
connectionConfig.width != null && connectionConfig.height != null;
const containerRef = useRef<HTMLDivElement>(null);
const displayRef = useRef<HTMLDivElement>(null);
const displayElementRef = useRef<HTMLElement | null>(null);
const clientRef = useRef<Guacamole.Client | null>(null);
const filesystemRef = useRef<Guacamole.Object | null>(null);
// Held in a ref so tearing down the client can report the loss without
// rebuilding the connect callback whenever the parent re-renders.
const onFilesystemRef = useRef(onFilesystem);
onFilesystemRef.current = onFilesystem;
const onStageChangeRef = useRef(onStageChange);
onStageChangeRef.current = onStageChange;
const keyboardRef = useRef<Guacamole.Keyboard | null>(null);
const unbindPointerRef = useRef<(() => void) | null>(null);
const scaleRef = useRef<number>(1);
const fitScaleRef = useRef<number>(1);
const zoomRef = useRef<number>(1);
const resizeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const hasKeyboardFocusRef = useRef(false);
const windowFocusedRef = useRef(
typeof document === "undefined" ? true : document.hasFocus(),
);
const hasInitiatedRef = useRef(false);
const isMountedRef = useRef(false);
const isConnectingRef = useRef(false);
const [isReady, setIsReady] = useState(false);
const [hasError, setHasError] = useState(false);
// RDP's guacd backend (FreeRDP) can report the Guacamole-level CONNECTED
// state from a single early sync before the actual RDP handshake with the
// remote host has finished negotiating - a handshake that can then still
// fail up to ~30s later. Waiting for a couple of real frame syncs before
// announcing the connection avoids a black screen with no connecting UI
// during that window; VNC/telnet fail before ever reaching CONNECTED, so
// they are unaffected.
const syncCountRef = useRef(0);
const disconnectClient = useCallback(() => {
unbindPointerRef.current?.();
unbindPointerRef.current = null;
const client = clientRef.current;
clientRef.current = null;
isConnectingRef.current = false;
if (filesystemRef.current) {
filesystemRef.current = null;
onFilesystemRef.current?.(null);
}
if (!client) return;
try {
client.disconnect();
} catch (error) {
console.warn("Failed to disconnect Guacamole client", error);
}
}, []);
const applyZoom = useCallback(
(nextZoom: number): number => {
const zoom = clampGuacamoleZoom(nextZoom);
zoomRef.current = zoom;
const display = clientRef.current?.getDisplay();
if (display && displayRef.current) {
const scale = fitScaleRef.current * zoom;
scaleRef.current = scale;
display.scale(scale);
displayRef.current.style.width = `${display.getWidth() * scale}px`;
displayRef.current.style.height = `${display.getHeight() * scale}px`;
}
onZoomChange?.(zoom);
return zoom;
},
[onZoomChange],
);
useImperativeHandle(ref, () => ({
disconnect: disconnectClient,
isConnected: () => isReady && !hasError,
sendKey: (keysym: number, pressed: boolean) => {
if (clientRef.current) {
clientRef.current.sendKeyEvent(pressed ? 1 : 0, keysym);
}
},
sendMouse: (x: number, y: number, buttonMask: number) => {
if (clientRef.current) {
clientRef.current.sendMouseState(
new Guacamole.Mouse.State({
x,
y,
left: !!(buttonMask & 1),
middle: !!(buttonMask & 2),
right: !!(buttonMask & 4),
}),
);
}
},
setClipboard: (data: string) => {
if (clientRef.current) {
const stream = clientRef.current.createClipboardStream("text/plain");
const writer = new Guacamole.StringWriter(stream);
writer.sendText(data);
writer.sendEnd();
}
},
getFilesystem: () => filesystemRef.current,
uploadFile: (file: File) => {
const client = clientRef.current;
if (!client) return Promise.reject(new Error("RDP session is not ready"));
return uploadFileToClient(
client as unknown as GuacamoleFileStreamClient,
file,
);
},
zoomIn: () => applyZoom(stepGuacamoleZoom(zoomRef.current, 1)),
zoomOut: () => applyZoom(stepGuacamoleZoom(zoomRef.current, -1)),
resetZoom: () => applyZoom(1),
}));
const getWebSocketConnection = useCallback(
async (
containerWidth: number,
containerHeight: number,
): Promise<{ url: string; query: string } | null> => {
try {
let token: string;
const connectionProtocol =
connectionConfig.protocol ?? connectionConfig.type;
// 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,
});
if (connectionConfig.token) {
token = connectionConfig.token;
} else {
const data = await getGuacamoleToken(
{
protocol: connectionProtocol ?? "rdp",
hostname: String(connectionConfig.hostname ?? ""),
port: connectionConfig.port,
username: connectionConfig.username,
password: connectionConfig.password,
domain: connectionConfig.domain,
security:
typeof connectionConfig.security === "string"
? connectionConfig.security
: undefined,
ignoreCert:
typeof connectionConfig.ignoreCert === "boolean"
? connectionConfig.ignoreCert
: undefined,
guacamoleConfig: connectionConfig.guacamoleConfig as Parameters<
typeof getGuacamoleToken
>[0]["guacamoleConfig"],
},
origin,
);
token = data.token;
}
const displaySize = getGuacamoleDisplaySize(
connectionConfig.width ?? containerWidth ?? 1280,
connectionConfig.height ?? containerHeight ?? 720,
connectionProtocol,
window.devicePixelRatio,
connectionConfig.dpi,
);
let wsBase: string | null;
if (isElectron()) {
const target = await buildOriginWsUrl({
origin,
localPort: 30008,
localPath: "/guacamole/websocket/",
remotePath: "/guacamole/websocket/",
includeJwt: false,
});
if (!target) {
onError?.(t("errors.remoteServerRequired"));
return null;
}
wsBase = target.url;
} else {
wsBase = buildGuacamoleWebSocketBaseUrl({
isDev,
isElectronApp: false,
isEmbeddedApp: false,
basePath: getBasePath(),
location: window.location,
});
}
const params = new URLSearchParams({
token,
width: String(displaySize.width),
height: String(displaySize.height),
});
if (displaySize.dpi) params.set("dpi", String(displaySize.dpi));
return { url: wsBase, query: params.toString() };
} catch (error) {
const errorMessage = getErrorMessage(error);
onError?.(errorMessage);
return null;
}
},
[connectionConfig, onError, t],
);
const refreshKeyboardHandlers = useCallback(() => {
const keyboard = keyboardRef.current;
const client = clientRef.current;
const displayElement = displayElementRef.current;
if (!keyboard) return;
const documentVisible =
typeof document === "undefined" || document.visibilityState === "visible";
const displayIsFocused =
!!displayElement &&
typeof document !== "undefined" &&
document.activeElement === displayElement;
const shouldCaptureInput =
!!client &&
!!displayElement &&
isVisible &&
documentVisible &&
windowFocusedRef.current &&
(hasKeyboardFocusRef.current || displayIsFocused);
if (!shouldCaptureInput) {
keyboard.onkeydown = null;
keyboard.onkeyup = null;
keyboard.reset();
return;
}
keyboard.onkeydown = (keysym: number) => {
if (!clientRef.current) return;
if (!isVisible || !windowFocusedRef.current) return;
const activeDisplay = displayElementRef.current;
const stillFocused =
!!activeDisplay &&
typeof document !== "undefined" &&
document.activeElement === activeDisplay;
if (!hasKeyboardFocusRef.current && !stillFocused) return;
clientRef.current.sendKeyEvent(1, keysym);
};
keyboard.onkeyup = (keysym: number) => {
if (!clientRef.current) return;
if (!isVisible || !windowFocusedRef.current) return;
const activeDisplay = displayElementRef.current;
const stillFocused =
!!activeDisplay &&
typeof document !== "undefined" &&
document.activeElement === activeDisplay;
if (!hasKeyboardFocusRef.current && !stillFocused) return;
clientRef.current.sendKeyEvent(0, keysym);
};
}, [isVisible]);
const rescaleDisplay = useCallback(
(immediate: boolean = false) => {
if (!clientRef.current || !containerRef.current) return;
const performRescale = () => {
if (!clientRef.current || !containerRef.current) return;
const display = clientRef.current.getDisplay();
const cWidth = containerRef.current.clientWidth;
const cHeight = containerRef.current.clientHeight;
const displayWidth = display.getWidth();
const displayHeight = display.getHeight();
if (
displayWidth > 0 &&
displayHeight > 0 &&
cWidth > 0 &&
cHeight > 0
) {
fitScaleRef.current = Math.min(
cWidth / displayWidth,
cHeight / displayHeight,
);
applyZoom(zoomRef.current);
}
};
if (immediate) {
performRescale();
} else {
if (resizeTimeoutRef.current) {
clearTimeout(resizeTimeoutRef.current);
}
resizeTimeoutRef.current = setTimeout(performRescale, 200);
}
},
[applyZoom],
);
const connect = useCallback(async () => {
if (isConnectingRef.current) return;
isConnectingRef.current = true;
setIsReady(false);
setHasError(false);
syncCountRef.current = 0;
// Let layout settle before measuring without depending on animation frames,
// which may be throttled while Electron windows or tabs are inactive.
await new Promise<void>((resolve) => setTimeout(resolve, 0));
if (!isMountedRef.current) {
isConnectingRef.current = false;
return;
}
// The tab's DOM node can still be display:none (and report 0x0) when this
// tab is restored in the background. Measuring then would force the
// window-size fallback, which ignores the tab bar and makes the remote
// resolution too tall (the bottom gets cut off). Poll briefly for a real
// size before connecting so we capture the actual visible viewport.
const measureContainer = () => {
const rect = containerRef.current?.getBoundingClientRect();
return { width: rect?.width || 0, height: rect?.height || 0 };
};
let { width: containerWidth, height: containerHeight } = measureContainer();
for (
let attempt = 0;
(containerWidth < 100 || containerHeight < 100) && attempt < 40;
attempt++
) {
await new Promise<void>((resolve) => setTimeout(resolve, 25));
if (!isMountedRef.current) {
isConnectingRef.current = false;
return;
}
({ width: containerWidth, height: containerHeight } = measureContainer());
}
if (containerWidth < 100 || containerHeight < 100) {
containerWidth = window.innerWidth || 1280;
containerHeight = window.innerHeight || 720;
}
const wsConnection = await getWebSocketConnection(
containerWidth,
containerHeight,
);
if (!isMountedRef.current) {
isConnectingRef.current = false;
return;
}
if (!wsConnection) {
isConnectingRef.current = false;
return;
}
const tunnel = new Guacamole.WebSocketTunnel(wsConnection.url);
const client = new Guacamole.Client(tunnel);
clientRef.current = client;
const display = client.getDisplay();
const displayElement = display.getElement();
displayElementRef.current = displayElement;
if (displayRef.current) {
displayRef.current.innerHTML = "";
displayRef.current.appendChild(displayElement);
}
displayElement.setAttribute("tabindex", "0");
displayElement.style.outline = "none";
// Reading navigator.clipboard outside a user gesture is denied by Safari
// and commonly denied by Chromium. The paste event carries the text under
// the browser's normal permission model, so use it on every browser and
// replace the original shortcut with an ordered clipboard update + Ctrl+V.
displayElement.addEventListener(
"keydown",
(event) => {
if (isPasteShortcut(event)) {
event.stopImmediatePropagation();
}
},
true,
);
displayElement.addEventListener(
"paste",
(event) => {
if (clientRef.current !== client) return;
const text = event.clipboardData?.getData("text/plain");
if (!text) return;
event.preventDefault();
event.stopImmediatePropagation();
pasteTextToRemote(client, text);
},
true,
);
display.onresize = () => {
if (!isMountedRef.current || clientRef.current !== client) return;
rescaleDisplay(true);
setIsReady(true);
};
const protocol = connectionConfig.protocol ?? connectionConfig.type;
if (protocol === "telnet" && isMountedRef.current) {
setIsReady(true);
}
const sendMouseState = (state: Guacamole.Mouse.State) => {
displayElement.focus({ preventScroll: true });
const scale = scaleRef.current;
const adjustedState = new Guacamole.Mouse.State(
Math.round(state.x / scale),
Math.round(state.y / scale),
state.left,
state.middle,
state.right,
state.up,
state.down,
) as Guacamole.Mouse.State;
client.sendMouseState(adjustedState);
};
unbindPointerRef.current?.();
unbindPointerRef.current = bindPointerInput(
displayElement,
touchMode,
sendMouseState,
);
const keyboard = new Guacamole.Keyboard(displayElement);
keyboardRef.current = keyboard;
const handleDisplayFocus = () => {
hasKeyboardFocusRef.current = true;
refreshKeyboardHandlers();
};
const handleDisplayBlur = () => {
hasKeyboardFocusRef.current = false;
refreshKeyboardHandlers();
};
displayElement.addEventListener("focus", handleDisplayFocus);
displayElement.addEventListener("blur", handleDisplayBlur);
displayElement.addEventListener("mousedown", handleDisplayFocus);
displayElement.addEventListener("touchstart", handleDisplayFocus, {
passive: true,
});
refreshKeyboardHandlers();
client.onstatechange = (state: number) => {
if (!isMountedRef.current || clientRef.current !== client) return;
onStageChangeRef.current?.(guacStateToStage(state));
switch (state) {
case 0:
break;
case 1:
break;
case 2:
break;
case 3:
isConnectingRef.current = false;
if (protocol !== "rdp") {
setIsReady(true);
onConnect?.();
}
// A configured resolution is the size the session should render at;
// resizing it to the container would discard it. rescaleDisplay still
// fits that fixed display into whatever space is available.
if (!hasConfiguredSize && containerRef.current) {
const rect = containerRef.current.getBoundingClientRect();
const size = getGuacamoleDisplaySize(
rect.width,
rect.height,
protocol,
window.devicePixelRatio,
connectionConfig.dpi,
);
client.sendSize(size.width, size.height);
}
rescaleDisplay(false);
break;
case 4:
break;
case 5:
isConnectingRef.current = false;
setIsReady(false);
setHasError(true);
hasKeyboardFocusRef.current = false;
refreshKeyboardHandlers();
onError?.(t("remoteDesktop.connectionError"));
onDisconnect?.();
break;
}
};
client.onerror = (error: Guacamole.Status) => {
if (!isMountedRef.current || clientRef.current !== client) return;
const errorMessage = error.message || t("remoteDesktop.connectionError");
setIsReady(false);
setHasError(true);
isConnectingRef.current = false;
onError?.(errorMessage);
};
if (protocol === "rdp") {
client.onsync = () => {
if (!isMountedRef.current || clientRef.current !== client) return;
if (syncCountRef.current >= 2) return;
syncCountRef.current += 1;
if (syncCountRef.current >= 2) {
setIsReady(true);
onConnect?.();
}
};
}
client.onclipboard = (stream: Guacamole.InputStream, mimetype: string) => {
if (mimetype === "text/plain") {
const reader = new Guacamole.StringReader(stream);
let data = "";
reader.ontext = (text: string) => {
data += text;
};
reader.onend = () => {
navigator.clipboard?.writeText?.(data).catch(() => {});
};
}
};
client.onaudio = (stream: Guacamole.InputStream, mimetype: string) => {
Guacamole.AudioPlayer.getInstance(stream, mimetype);
};
// Only fires when the connection enables drive redirection; guacd exposes
// the redirected drive as a single filesystem object.
client.onfilesystem = (filesystem: Guacamole.Object) => {
if (!isMountedRef.current || clientRef.current !== client) return;
filesystemRef.current = filesystem;
onFilesystemRef.current?.(filesystem);
filesystem.onundefine = () => {
if (filesystemRef.current !== filesystem) return;
filesystemRef.current = null;
onFilesystemRef.current?.(null);
};
};
client.onfile = (
stream: Guacamole.InputStream,
mimetype: string,
filename: string,
) => {
const reader = new Guacamole.BlobReader(stream, mimetype);
reader.onend = () => {
const blob = reader.getBlob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
};
stream.sendAck("OK", Guacamole.Status.Code.SUCCESS);
};
try {
client.connect(wsConnection.query);
} catch (error) {
isConnectingRef.current = false;
if (!isMountedRef.current) return;
setIsReady(false);
setHasError(true);
onError?.(getErrorMessage(error, t("remoteDesktop.connectionError")));
}
}, [
getWebSocketConnection,
onConnect,
onDisconnect,
onError,
refreshKeyboardHandlers,
rescaleDisplay,
connectionConfig.protocol,
connectionConfig.type,
connectionConfig.dpi,
hasConfiguredSize,
touchMode,
t,
]);
useEffect(() => {
isMountedRef.current = true;
if (isVisible && !hasInitiatedRef.current) {
hasInitiatedRef.current = true;
connect();
}
}, [isVisible, connect]);
useEffect(() => {
if (!isVisible) {
hasKeyboardFocusRef.current = false;
}
refreshKeyboardHandlers();
}, [isVisible, refreshKeyboardHandlers]);
useEffect(() => {
const handleWindowFocus = () => {
windowFocusedRef.current = true;
refreshKeyboardHandlers();
};
const handleWindowBlur = () => {
windowFocusedRef.current = false;
hasKeyboardFocusRef.current = false;
refreshKeyboardHandlers();
};
const handleVisibilityChange = () => {
windowFocusedRef.current =
document.visibilityState === "visible" && document.hasFocus();
if (document.visibilityState !== "visible") {
hasKeyboardFocusRef.current = false;
}
refreshKeyboardHandlers();
};
window.addEventListener("focus", handleWindowFocus);
window.addEventListener("blur", handleWindowBlur);
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
window.removeEventListener("focus", handleWindowFocus);
window.removeEventListener("blur", handleWindowBlur);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [refreshKeyboardHandlers]);
useEffect(() => {
return () => {
isMountedRef.current = false;
hasInitiatedRef.current = false;
isConnectingRef.current = false;
if (resizeTimeoutRef.current) {
clearTimeout(resizeTimeoutRef.current);
}
disconnectClient();
displayElementRef.current = null;
};
}, [disconnectClient]);
useEffect(() => {
if (!containerRef.current) return;
const resizeObserver = new ResizeObserver(() => {
if (resizeTimeoutRef.current) clearTimeout(resizeTimeoutRef.current);
resizeTimeoutRef.current = setTimeout(() => {
if (clientRef.current && containerRef.current) {
const rect = containerRef.current.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
if (!hasConfiguredSize) {
const size = getGuacamoleDisplaySize(
rect.width,
rect.height,
connectionConfig.protocol ?? connectionConfig.type,
window.devicePixelRatio,
connectionConfig.dpi,
);
clientRef.current.sendSize(size.width, size.height);
}
rescaleDisplay(true);
}
}
}, 150);
});
resizeObserver.observe(containerRef.current);
return () => {
resizeObserver.disconnect();
};
}, [
connectionConfig.dpi,
connectionConfig.protocol,
connectionConfig.type,
hasConfiguredSize,
rescaleDisplay,
]);
const syncClipboard = useCallback(() => {
const client = clientRef.current;
if (!client || !navigator.clipboard?.readText) return;
navigator.clipboard
.readText()
.then((text) => {
if (text) {
const stream = client.createClipboardStream("text/plain");
const writer = new Guacamole.StringWriter(stream);
writer.sendText(text);
writer.sendEnd();
}
})
.catch(() => {});
}, []);
useEffect(() => {
if (isVisible && isReady) {
syncClipboard();
}
}, [isVisible, isReady, syncClipboard]);
useEffect(() => {
const container = containerRef.current;
const protocol = connectionConfig.protocol ?? connectionConfig.type;
if (!container || !isReady || protocol !== "vnc") return;
let pinchDistance = 0;
let pinchZoom = zoomRef.current;
const distance = (touches: TouchList) =>
Math.hypot(
touches[0].clientX - touches[1].clientX,
touches[0].clientY - touches[1].clientY,
);
const onTouchStart = (event: TouchEvent) => {
if (event.touches.length !== 2) return;
event.preventDefault();
event.stopPropagation();
pinchDistance = distance(event.touches);
pinchZoom = zoomRef.current;
};
const onTouchMove = (event: TouchEvent) => {
if (event.touches.length !== 2 || pinchDistance === 0) return;
event.preventDefault();
event.stopPropagation();
applyZoom(pinchZoom * (distance(event.touches) / pinchDistance));
};
const onTouchEnd = () => {
pinchDistance = 0;
};
container.addEventListener("touchstart", onTouchStart, {
passive: false,
capture: true,
});
container.addEventListener("touchmove", onTouchMove, {
passive: false,
capture: true,
});
container.addEventListener("touchend", onTouchEnd, { capture: true });
return () => {
container.removeEventListener("touchstart", onTouchStart, true);
container.removeEventListener("touchmove", onTouchMove, true);
container.removeEventListener("touchend", onTouchEnd, true);
};
}, [applyZoom, connectionConfig.protocol, connectionConfig.type, isReady]);
useEffect(() => {
const container = containerRef.current;
if (!container || !isReady) return;
const handleFocus = () => syncClipboard();
container.addEventListener("mouseenter", handleFocus);
return () => {
container.removeEventListener("mouseenter", handleFocus);
};
}, [isReady, syncClipboard]);
const canDropFiles = allowUpload && onDropFiles != null;
const [isDraggingFiles, setIsDraggingFiles] = useState(false);
// Nested elements fire dragleave as the pointer crosses them, so track depth
// rather than clearing the highlight on the first leave.
const dragDepthRef = useRef(0);
const handleDragEnter = useCallback(
(event: React.DragEvent) => {
if (!hasDraggedFiles(event.dataTransfer.types)) return;
event.preventDefault();
if (!canDropFiles) return;
dragDepthRef.current += 1;
setIsDraggingFiles(true);
},
[canDropFiles],
);
const handleDragLeave = useCallback(() => {
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (dragDepthRef.current === 0) setIsDraggingFiles(false);
}, []);
const handleDrop = useCallback(
(event: React.DragEvent) => {
if (!hasDraggedFiles(event.dataTransfer.types)) return;
event.preventDefault();
dragDepthRef.current = 0;
setIsDraggingFiles(false);
const files = Array.from(event.dataTransfer.files);
const disposition = getFileDropDisposition(
event.dataTransfer.types,
files.length,
canDropFiles,
);
if (disposition === "upload") onDropFiles?.(files);
if (disposition === "reject") onDropUnavailable?.();
},
[canDropFiles, onDropFiles, onDropUnavailable],
);
return (
<div
ref={containerRef}
className="absolute inset-0 overflow-auto"
style={{ backgroundColor: "var(--bg-base)" }}
onDragEnter={handleDragEnter}
onDragOver={(event) => {
if (hasDraggedFiles(event.dataTransfer.types)) event.preventDefault();
}}
onDragLeave={canDropFiles ? handleDragLeave : undefined}
onDrop={handleDrop}
>
<div
ref={displayRef}
className="relative flex min-h-full min-w-full items-center justify-center"
style={{
cursor: isReady ? "none" : "default",
visibility: isReady ? "visible" : "hidden",
}}
/>
{isDraggingFiles && (
<div className="absolute inset-0 z-30 flex items-center justify-center pointer-events-none bg-background/70 backdrop-blur-sm">
<div className="flex items-center gap-2 rounded-sm border-2 border-dashed border-border px-4 py-3 text-sm font-semibold">
<Upload className="size-4" />
{t("remoteDesktop.files.dropToUpload")}
</div>
</div>
)}
</div>
);
});
@@ -0,0 +1,245 @@
import { getErrorMessage } from "@/lib/error-message.js";
import { useCallback, useEffect, useRef, useState } from "react";
import Guacamole from "guacamole-common-js";
import { useTranslation } from "react-i18next";
import {
ArrowUp,
Download,
File as FileIcon,
Folder,
RefreshCw,
Upload,
X,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/button.tsx";
import {
downloadFile,
listDirectory,
parentPath,
saveBlobAs,
uploadFile,
describeUploadError,
type RemoteFileEntry,
} from "./guacamole-filesystem.ts";
interface GuacamoleFileBrowserProps {
filesystem: Guacamole.Object;
allowUpload: boolean;
allowDownload: boolean;
pendingUploads: File[];
onPendingUploadsHandled: () => void;
onClose: () => void;
}
export function GuacamoleFileBrowser({
filesystem,
allowUpload,
allowDownload,
pendingUploads,
onPendingUploadsHandled,
onClose,
}: GuacamoleFileBrowserProps) {
const { t } = useTranslation();
const [path, setPath] = useState("/");
const [entries, setEntries] = useState<RemoteFileEntry[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [busyName, setBusyName] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const refresh = useCallback(
async (target: string) => {
setLoading(true);
setError(null);
try {
setEntries(await listDirectory(filesystem, target));
} catch (err) {
setEntries([]);
setError(getErrorMessage(err, t("remoteDesktop.files.listFailed")));
} finally {
setLoading(false);
}
},
[filesystem, t],
);
useEffect(() => {
void refresh(path);
}, [refresh, path]);
const upload = useCallback(
async (files: File[]) => {
for (const file of files) {
setBusyName(file.name);
try {
await uploadFile(filesystem, path, file);
toast.success(t("remoteDesktop.files.uploaded", { name: file.name }));
} catch (err) {
toast.error(
describeUploadError(err, (key) =>
t(`remoteDesktop.files.${key}`, { name: file.name }),
),
);
} finally {
setBusyName(null);
}
}
void refresh(path);
},
[filesystem, path, refresh, t],
);
// Files dropped on the display land here so they share the browser's current
// directory rather than always going to the drive root.
useEffect(() => {
if (pendingUploads.length === 0) return;
onPendingUploadsHandled();
if (!allowUpload) {
toast.error(t("remoteDesktop.files.uploadDisabled"));
return;
}
void upload(pendingUploads);
}, [pendingUploads, onPendingUploadsHandled, allowUpload, upload, t]);
const download = useCallback(
async (entry: RemoteFileEntry) => {
setBusyName(entry.name);
try {
const { blob, filename } = await downloadFile(filesystem, entry.path);
saveBlobAs(blob, filename);
} catch (err) {
toast.error(
err instanceof Error
? err.message
: t("remoteDesktop.files.downloadFailed", { name: entry.name }),
);
} finally {
setBusyName(null);
}
},
[filesystem, t],
);
return (
<div className="absolute right-2 top-2 bottom-2 z-30 flex w-80 flex-col rounded-sm border border-border bg-background/95 shadow-lg backdrop-blur-sm">
<div className="flex items-center gap-1 border-b border-border px-2 py-1.5">
<span className="flex-1 truncate text-xs font-semibold">
{t("remoteDesktop.files.title")}
</span>
<Button
variant="ghost"
size="icon"
className="size-6"
onClick={onClose}
aria-label={t("common.close")}
>
<X className="size-3.5" />
</Button>
</div>
<div className="flex items-center gap-1 border-b border-border px-2 py-1.5">
<Button
variant="ghost"
size="icon"
className="size-6"
disabled={path === "/"}
onClick={() => setPath(parentPath(path))}
aria-label={t("remoteDesktop.files.parent")}
>
<ArrowUp className="size-3.5" />
</Button>
<span className="flex-1 truncate text-[11px] text-muted-foreground">
{path}
</span>
<Button
variant="ghost"
size="icon"
className="size-6"
onClick={() => void refresh(path)}
aria-label={t("remoteDesktop.files.refresh")}
>
<RefreshCw className="size-3.5" />
</Button>
</div>
<div className="flex-1 overflow-y-auto">
{loading && (
<p className="px-2 py-3 text-[11px] text-muted-foreground">
{t("common.loading")}
</p>
)}
{!loading && error && (
<p className="px-2 py-3 text-[11px] text-red-500">{error}</p>
)}
{!loading && !error && entries.length === 0 && (
<p className="px-2 py-3 text-[11px] text-muted-foreground">
{t("remoteDesktop.files.empty")}
</p>
)}
{!loading &&
!error &&
entries.map((entry) => (
<div
key={entry.path}
className="flex items-center gap-1.5 px-2 py-1 hover:bg-muted"
>
{entry.isDirectory ? (
<Folder className="size-3.5 shrink-0 text-muted-foreground" />
) : (
<FileIcon className="size-3.5 shrink-0 text-muted-foreground" />
)}
<button
type="button"
className="flex-1 truncate text-left text-[11px] disabled:opacity-60"
disabled={!entry.isDirectory}
onClick={() => entry.isDirectory && setPath(entry.path)}
>
{entry.name}
</button>
{!entry.isDirectory && allowDownload && (
<Button
variant="ghost"
size="icon"
className="size-5 shrink-0"
disabled={busyName === entry.name}
onClick={() => void download(entry)}
aria-label={t("remoteDesktop.files.download")}
>
<Download className="size-3" />
</Button>
)}
</div>
))}
</div>
{allowUpload && (
<div className="border-t border-border px-2 py-1.5">
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => {
const files = Array.from(e.target.files ?? []);
e.target.value = "";
if (files.length > 0) void upload(files);
}}
/>
<Button
variant="outline"
size="sm"
className="h-7 w-full text-[11px]"
disabled={busyName !== null}
onClick={() => fileInputRef.current?.click()}
>
<Upload className="mr-1 size-3" />
{busyName !== null
? t("remoteDesktop.files.uploading", { name: busyName })
: t("remoteDesktop.files.upload")}
</Button>
</div>
)}
</div>
);
}
@@ -0,0 +1,144 @@
import React from "react";
import { fireEvent, render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { GuacamoleToolbar } from "./GuacamoleToolbar.js";
import type { GuacamoleDisplayHandle } from "./GuacamoleDisplay.js";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
describe("GuacamoleToolbar Windows key", () => {
it("sends Super_L for Win shortcuts and the sticky modifier", () => {
const sendKey = vi.fn();
const displayRef = {
current: {
disconnect: vi.fn(),
isConnected: () => true,
sendKey,
sendMouse: vi.fn(),
setClipboard: vi.fn(),
getFilesystem: () => null,
uploadFile: async () => {},
zoomIn: vi.fn(() => 1.25),
zoomOut: vi.fn(() => 0.75),
resetZoom: vi.fn(() => 1),
} satisfies GuacamoleDisplayHandle,
} as React.RefObject<GuacamoleDisplayHandle>;
const { getByText } = render(
<GuacamoleToolbar
displayRef={displayRef}
protocol="rdp"
metaKeyFamily="win"
/>,
);
fireEvent.click(getByText("Win+L"));
expect(sendKey.mock.calls).toEqual([
[0xffeb, true],
[0x006c, true],
[0x006c, false],
[0xffeb, false],
]);
sendKey.mockClear();
fireEvent.click(getByText("Win"));
expect(sendKey.mock.calls).toEqual([
[0xffeb, true],
[0xffeb, false],
]);
sendKey.mockClear();
fireEvent.click(getByText("remoteDesktop.toolbar.win"));
expect(sendKey).toHaveBeenCalledWith(0xffeb, true);
});
it("exposes VNC zoom controls without showing them for RDP", () => {
const zoomIn = vi.fn(() => 1.25);
const zoomOut = vi.fn(() => 0.75);
const resetZoom = vi.fn(() => 1);
const displayRef = {
current: {
disconnect: vi.fn(),
isConnected: () => true,
sendKey: vi.fn(),
sendMouse: vi.fn(),
setClipboard: vi.fn(),
getFilesystem: () => null,
uploadFile: async () => {},
zoomIn,
zoomOut,
resetZoom,
} satisfies GuacamoleDisplayHandle,
} as React.RefObject<GuacamoleDisplayHandle>;
const { getByLabelText, getByText } = render(
<GuacamoleToolbar displayRef={displayRef} protocol="vnc" zoom={1.25} />,
);
fireEvent.click(getByLabelText("remoteDesktop.toolbar.zoomOut"));
fireEvent.click(getByLabelText("remoteDesktop.toolbar.zoomIn"));
fireEvent.click(getByText("125%"));
expect(zoomOut).toHaveBeenCalledOnce();
expect(zoomIn).toHaveBeenCalledOnce();
expect(resetZoom).toHaveBeenCalledOnce();
});
it("offers an explicit session-only hide action", () => {
const onHide = vi.fn();
const displayRef = {
current: {
disconnect: vi.fn(),
isConnected: () => true,
sendKey: vi.fn(),
sendMouse: vi.fn(),
setClipboard: vi.fn(),
getFilesystem: () => null,
uploadFile: vi.fn(),
zoomIn: vi.fn(() => 1.25),
zoomOut: vi.fn(() => 0.75),
resetZoom: vi.fn(() => 1),
} satisfies GuacamoleDisplayHandle,
} as React.RefObject<GuacamoleDisplayHandle>;
const { getByLabelText } = render(
<GuacamoleToolbar
displayRef={displayRef}
protocol="rdp"
onHide={onHide}
/>,
);
fireEvent.click(getByLabelText("remoteDesktop.toolbar.hide"));
expect(onHide).toHaveBeenCalledOnce();
});
it("labels the VNC meta key Super instead of Windows", () => {
const displayRef = {
current: {
disconnect: vi.fn(),
isConnected: () => true,
sendKey: vi.fn(),
sendMouse: vi.fn(),
setClipboard: vi.fn(),
getFilesystem: () => null,
uploadFile: vi.fn(),
zoomIn: vi.fn(() => 1.25),
zoomOut: vi.fn(() => 0.75),
resetZoom: vi.fn(() => 1),
} satisfies GuacamoleDisplayHandle,
} as React.RefObject<GuacamoleDisplayHandle>;
const { getByText, queryByText } = render(
<GuacamoleToolbar
displayRef={displayRef}
protocol="vnc"
metaKeyFamily="super"
/>,
);
expect(getByText("Super+L")).toBeTruthy();
expect(getByText("Super")).toBeTruthy();
expect(queryByText("Win+L")).toBeNull();
expect(queryByText("Win")).toBeNull();
});
});
@@ -0,0 +1,606 @@
import React, {
useState,
useRef,
useCallback,
useLayoutEffect,
useEffect,
} from "react";
import {
GripVertical,
Monitor,
ChevronLeft,
ChevronRight,
ChevronUp,
ChevronDown,
ChevronsLeftRight,
FolderOpen,
Touchpad,
MousePointer,
ZoomIn,
ZoomOut,
Scan,
X,
} from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/tooltip.tsx";
import type {
GuacamoleDisplayHandle,
GuacamoleTouchMode,
} from "./GuacamoleDisplay.tsx";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import {
detectRuntimeMetaKeyFamily,
metaKeyLabels,
type MetaKeyFamily,
} from "./guacamole-meta-key.ts";
interface GuacamoleToolbarProps {
displayRef: React.RefObject<GuacamoleDisplayHandle>;
protocol: "rdp" | "vnc" | "telnet";
touchMode?: GuacamoleTouchMode | null;
hasFilesystem?: boolean;
fileBrowserOpen?: boolean;
onToggleFileBrowser?: () => void;
onTouchModeChange?: (mode: GuacamoleTouchMode) => void;
zoom?: number;
onHide?: () => void;
metaKeyFamily?: MetaKeyFamily;
}
const MODIFIER_KEYSYMS = {
ctrl: 0xffe3,
alt: 0xffe9,
shift: 0xffe1,
win: 0xffeb,
} as const;
const FKEY_KEYSYMS = Array.from({ length: 12 }, (_, i) => 0xffbe + i);
const BTN_BASE =
"flex items-center justify-center gap-1 h-7 px-2 text-[10px] font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors rounded-sm whitespace-nowrap select-none";
const BTN_ICON =
"flex items-center justify-center size-7 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors rounded-sm select-none";
const SEP = "w-px h-5 bg-border mx-0.5 shrink-0";
function TipBtn({
tooltip,
onClick,
className,
children,
}: {
tooltip: string;
onClick: () => void;
className?: string;
children: React.ReactNode;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={tooltip}
onClick={onClick}
className={cn(BTN_BASE, className)}
>
{children}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{tooltip}
</TooltipContent>
</Tooltip>
);
}
function TipIconBtn({
tooltip,
onClick,
className,
children,
}: {
tooltip: string;
onClick: () => void;
className?: string;
children: React.ReactNode;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={tooltip}
onClick={onClick}
className={cn(BTN_ICON, className)}
>
{children}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{tooltip}
</TooltipContent>
</Tooltip>
);
}
export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
displayRef,
protocol,
touchMode,
hasFilesystem = false,
fileBrowserOpen = false,
onToggleFileBrowser,
onTouchModeChange,
zoom = 1,
onHide,
metaKeyFamily,
}) => {
const { t } = useTranslation();
const meta = metaKeyLabels(
metaKeyFamily ?? detectRuntimeMetaKeyFamily(protocol),
);
const [position, setPosition] = useState({ x: 0, y: 12 });
const [collapsed, setCollapsed] = useState(false);
const [showFKeys, setShowFKeys] = useState(false);
const [stickyKeys, setStickyKeys] = useState<Record<number, boolean>>({
[MODIFIER_KEYSYMS.ctrl]: false,
[MODIFIER_KEYSYMS.alt]: false,
[MODIFIER_KEYSYMS.shift]: false,
[MODIFIER_KEYSYMS.win]: false,
});
const toolbarRef = useRef<HTMLDivElement>(null);
const isDraggingRef = useRef(false);
const dragOriginRef = useRef({ mouseX: 0, mouseY: 0, posX: 0, posY: 0 });
const [isDragging, setIsDragging] = useState(false);
useLayoutEffect(() => {
const el = toolbarRef.current;
if (!el) return;
const parent = el.offsetParent as HTMLElement | null;
if (!parent) return;
const parentW = parent.clientWidth;
const toolbarW = el.offsetWidth;
setPosition((p) => ({ ...p, x: Math.max(0, (parentW - toolbarW) / 2) }));
}, [collapsed]);
useEffect(() => {
if (!isDragging) return;
const onMove = (e: MouseEvent) => {
if (!isDraggingRef.current) return;
const parent = toolbarRef.current?.offsetParent as HTMLElement | null;
const parentW = parent?.clientWidth ?? Infinity;
const parentH = parent?.clientHeight ?? Infinity;
const toolbarW = toolbarRef.current?.offsetWidth ?? 0;
const toolbarH = toolbarRef.current?.offsetHeight ?? 0;
const dx = e.clientX - dragOriginRef.current.mouseX;
const dy = e.clientY - dragOriginRef.current.mouseY;
setPosition({
x: Math.max(
0,
Math.min(dragOriginRef.current.posX + dx, parentW - toolbarW),
),
y: Math.max(
0,
Math.min(dragOriginRef.current.posY + dy, parentH - toolbarH),
),
});
};
const onUp = () => {
isDraggingRef.current = false;
setIsDragging(false);
document.body.style.userSelect = "";
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
return () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
}, [isDragging]);
const startDrag = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
isDraggingRef.current = true;
dragOriginRef.current = {
mouseX: e.clientX,
mouseY: e.clientY,
posX: position.x,
posY: position.y,
};
document.body.style.userSelect = "none";
setIsDragging(true);
},
[position],
);
const releaseStickyKeys = useCallback(() => {
const display = displayRef.current;
if (!display) return;
setStickyKeys((prev) => {
const next = { ...prev };
for (const [ksStr, active] of Object.entries(prev)) {
if (active) {
display.sendKey(Number(ksStr), false);
next[Number(ksStr)] = false;
}
}
return next;
});
}, [displayRef]);
const sendCombo = useCallback(
(...keysyms: number[]) => {
const display = displayRef.current;
if (!display) return;
for (const k of keysyms) display.sendKey(k, true);
for (const k of [...keysyms].reverse()) display.sendKey(k, false);
releaseStickyKeys();
},
[displayRef, releaseStickyKeys],
);
const toggleStickyKey = useCallback(
(keysym: number) => {
const display = displayRef.current;
if (!display) return;
setStickyKeys((prev) => {
const isActive = prev[keysym];
display.sendKey(keysym, !isActive);
return { ...prev, [keysym]: !isActive };
});
},
[displayRef],
);
const isRdpVnc = protocol === "rdp" || protocol === "vnc";
const containerStyle: React.CSSProperties = {
position: "absolute",
left: position.x,
top: position.y,
zIndex: 20,
};
return (
<TooltipProvider delayDuration={500}>
<div
ref={toolbarRef}
style={containerStyle}
onMouseDown={(e) => e.preventDefault()}
>
{collapsed ? (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center bg-background/85 backdrop-blur-sm border border-border shadow-lg rounded-sm overflow-hidden">
<button
type="button"
onMouseDown={startDrag}
className="flex items-center justify-center size-7 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors cursor-grab active:cursor-grabbing"
>
<GripVertical className="size-3" />
</button>
<div className="w-px h-4 bg-border" />
<button
type="button"
onClick={() => setCollapsed(false)}
className="flex items-center justify-center size-7 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
<Monitor className="size-3.5" />
</button>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{t("remoteDesktop.toolbar.expand")}
</TooltipContent>
</Tooltip>
) : (
<div className="flex items-center bg-background/85 backdrop-blur-sm border border-border shadow-lg rounded-sm px-0.5 py-0.5 gap-0">
{/* Drag handle */}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onMouseDown={startDrag}
className="flex items-center justify-center h-7 px-1 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors rounded-sm cursor-grab active:cursor-grabbing"
>
<GripVertical className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{t("remoteDesktop.toolbar.dragHandle")}
</TooltipContent>
</Tooltip>
{/* Touch mode toggle — touch devices only */}
{touchMode != null && onTouchModeChange && (
<>
<div className={SEP} />
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() =>
onTouchModeChange(
touchMode === "touchscreen"
? "touchpad"
: "touchscreen",
)
}
className={cn(BTN_ICON)}
>
{touchMode === "touchscreen" ? (
<MousePointer className="size-3.5" />
) : (
<Touchpad className="size-3.5" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{touchMode === "touchscreen"
? t("remoteDesktop.toolbar.switchToTrackpad")
: t("remoteDesktop.toolbar.switchToTouch")}
</TooltipContent>
</Tooltip>
</>
)}
{/* Drive files — only once guacd reports a redirected filesystem */}
{hasFilesystem && onToggleFileBrowser && (
<>
<div className={SEP} />
<TipIconBtn
tooltip={t("remoteDesktop.files.title")}
onClick={onToggleFileBrowser}
className={cn(fileBrowserOpen && "bg-muted text-foreground")}
>
<FolderOpen className="size-3.5" />
</TipIconBtn>
</>
)}
{protocol === "vnc" && (
<>
<div className={SEP} />
<TipIconBtn
tooltip={t("remoteDesktop.toolbar.zoomOut")}
onClick={() => displayRef.current?.zoomOut()}
>
<ZoomOut className="size-3.5" />
</TipIconBtn>
<TipBtn
tooltip={t("remoteDesktop.toolbar.resetZoom")}
onClick={() => displayRef.current?.resetZoom()}
className="min-w-12 tabular-nums"
>
{Math.round(zoom * 100)}%
</TipBtn>
<TipIconBtn
tooltip={t("remoteDesktop.toolbar.zoomIn")}
onClick={() => displayRef.current?.zoomIn()}
>
<ZoomIn className="size-3.5" />
</TipIconBtn>
<TipIconBtn
tooltip={t("remoteDesktop.toolbar.fitToScreen")}
onClick={() => displayRef.current?.resetZoom()}
>
<Scan className="size-3.5" />
</TipIconBtn>
</>
)}
{/* System combos — RDP/VNC only */}
{isRdpVnc && (
<>
<div className={SEP} />
<TipBtn
tooltip={t("remoteDesktop.toolbar.ctrlAltDel")}
onClick={() => sendCombo(0xffe3, 0xffe9, 0xffff)}
>
CAD
</TipBtn>
<TipBtn
tooltip={t(meta.lockTooltip)}
onClick={() => sendCombo(MODIFIER_KEYSYMS.win, 0x006c)}
>
{meta.lock}
</TipBtn>
<TipBtn
tooltip={t(meta.keyTooltip)}
onClick={() => sendCombo(MODIFIER_KEYSYMS.win)}
>
{meta.short}
</TipBtn>
</>
)}
{/* Sticky modifiers — RDP/VNC only */}
{isRdpVnc && (
<>
<div className={SEP} />
{(
[
[
"ctrl",
MODIFIER_KEYSYMS.ctrl,
t("remoteDesktop.toolbar.ctrl"),
],
[
"alt",
MODIFIER_KEYSYMS.alt,
t("remoteDesktop.toolbar.alt"),
],
[
"shift",
MODIFIER_KEYSYMS.shift,
t("remoteDesktop.toolbar.shift"),
],
["win", MODIFIER_KEYSYMS.win, t(meta.stickyKey)],
] as [string, number, string][]
).map(([key, ks, label]) => (
<Tooltip key={key}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => toggleStickyKey(ks)}
className={cn(
BTN_BASE,
stickyKeys[ks] &&
"bg-primary/15 text-primary border border-primary/30",
)}
>
{label}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{stickyKeys[ks]
? t("remoteDesktop.toolbar.stickyActive", {
key: label,
})
: t("remoteDesktop.toolbar.stickyInactive", {
key: label,
})}
</TooltipContent>
</Tooltip>
))}
</>
)}
{/* Function key toggle */}
<div className={SEP} />
<TipBtn
tooltip={t("remoteDesktop.toolbar.fnToggle")}
onClick={() => setShowFKeys((v) => !v)}
className={cn(
showFKeys &&
"bg-primary/15 text-primary border border-primary/30",
)}
>
Fn
</TipBtn>
{/* F1-F12 row */}
{showFKeys &&
FKEY_KEYSYMS.map((ks, i) => (
<TipBtn
key={ks}
tooltip={`F${i + 1}`}
onClick={() => sendCombo(ks)}
>
F{i + 1}
</TipBtn>
))}
{/* Navigation */}
<div className={SEP} />
<TipBtn
tooltip={t("remoteDesktop.toolbar.esc")}
onClick={() => sendCombo(0xff1b)}
>
Esc
</TipBtn>
<TipBtn
tooltip={t("remoteDesktop.toolbar.tab")}
onClick={() => sendCombo(0xff09)}
>
Tab
</TipBtn>
<TipBtn
tooltip={t("remoteDesktop.toolbar.home")}
onClick={() => sendCombo(0xff50)}
>
Home
</TipBtn>
<TipBtn
tooltip={t("remoteDesktop.toolbar.end")}
onClick={() => sendCombo(0xff57)}
>
End
</TipBtn>
<TipBtn
tooltip={t("remoteDesktop.toolbar.pageUp")}
onClick={() => sendCombo(0xff55)}
>
PgUp
</TipBtn>
<TipBtn
tooltip={t("remoteDesktop.toolbar.pageDown")}
onClick={() => sendCombo(0xff56)}
>
PgDn
</TipBtn>
{/* Arrow cluster */}
<div className="flex flex-col ml-0.5">
<div className="flex justify-center">
<TipIconBtn
tooltip={t("remoteDesktop.toolbar.arrowUp")}
onClick={() => sendCombo(0xff52)}
>
<ChevronUp className="size-3" />
</TipIconBtn>
</div>
<div className="flex">
<TipIconBtn
tooltip={t("remoteDesktop.toolbar.arrowLeft")}
onClick={() => sendCombo(0xff51)}
>
<ChevronLeft className="size-3" />
</TipIconBtn>
<TipIconBtn
tooltip={t("remoteDesktop.toolbar.arrowDown")}
onClick={() => sendCombo(0xff54)}
>
<ChevronDown className="size-3" />
</TipIconBtn>
<TipIconBtn
tooltip={t("remoteDesktop.toolbar.arrowRight")}
onClick={() => sendCombo(0xff53)}
>
<ChevronRight className="size-3" />
</TipIconBtn>
</div>
</div>
{/* Collapse */}
<div className="w-px h-5 bg-border mx-0.5 shrink-0" />
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setCollapsed(true)}
className={cn(BTN_ICON)}
>
<ChevronsLeftRight className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{t("remoteDesktop.toolbar.collapse")}
</TooltipContent>
</Tooltip>
{onHide && (
<TipIconBtn
tooltip={t("remoteDesktop.toolbar.hide")}
onClick={onHide}
>
<X className="size-3.5" />
</TipIconBtn>
)}
</div>
)}
</div>
</TooltipProvider>
);
};
@@ -0,0 +1,173 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
const authApiMock = vi.hoisted(() => ({
get: vi.fn(async () => ({ data: { guacd: { status: "disconnected" } } })),
post: vi.fn(async () => ({ data: { token: "local-token" } })),
}));
const remoteApiMock = vi.hoisted(() => ({
get: vi.fn(async () => ({ data: { guacd: { status: "connected" } } })),
post: vi.fn(async () => ({ data: { token: "remote-token" } })),
}));
const isElectronMock = vi.hoisted(() => vi.fn(() => false));
const resolveRemoteHostIdMock = vi.hoisted(() => vi.fn());
vi.mock("@/main-axios", () => ({
authApi: authApiMock,
getRemoteGuacamoleApi: () => remoteApiMock,
isElectron: isElectronMock,
handleApiError: (error: unknown) => error,
}));
vi.mock("@/lib/remote-server-api", () => ({
resolveRemoteHostId: resolveRemoteHostIdMock,
}));
import {
getGuacdStatus,
getGuacamoleConnectionId,
getGuacamoleTokenFromHost,
} from "./guacamole-api";
beforeEach(() => {
authApiMock.get.mockClear();
authApiMock.post.mockClear();
remoteApiMock.get.mockClear();
remoteApiMock.post.mockClear();
resolveRemoteHostIdMock.mockReset();
});
describe("guacamole API origin", () => {
it("uses the shared instance in the browser", async () => {
isElectronMock.mockReturnValue(false);
await getGuacdStatus("remote");
await getGuacamoleTokenFromHost(9, "remote", "vnc");
expect(authApiMock.get).toHaveBeenCalledWith("/guacamole/status");
expect(authApiMock.post).toHaveBeenCalledOnce();
expect(remoteApiMock.get).not.toHaveBeenCalled();
expect(remoteApiMock.post).not.toHaveBeenCalled();
});
it("uses the connected remote server in the desktop app", async () => {
isElectronMock.mockReturnValue(true);
// The embedded backend has no guacd, so asking it reports "disconnected"
// even when the connected server can serve the session.
const status = await getGuacdStatus("remote");
const token = await getGuacamoleTokenFromHost(9, "remote", "vnc");
expect(status.guacd.status).toBe("connected");
expect(token.token).toBe("remote-token");
expect(remoteApiMock.get).toHaveBeenCalledWith("/guacamole/status");
expect(remoteApiMock.post).toHaveBeenCalledOnce();
expect(authApiMock.get).not.toHaveBeenCalled();
expect(authApiMock.post).not.toHaveBeenCalled();
});
it("sends the connect-host payload unchanged to the remote server", async () => {
isElectronMock.mockReturnValue(true);
resolveRemoteHostIdMock.mockResolvedValue(41);
await getGuacamoleTokenFromHost(
9,
"remote",
"rdp",
{
username: "admin",
password: "secret",
domain: "EXAMPLE",
},
"host-sync-id",
);
expect(remoteApiMock.post).toHaveBeenCalledWith(
"/guacamole/connect-host/41",
{
protocol: "rdp",
promptedUsername: "admin",
promptedPassword: "secret",
promptedDomain: "EXAMPLE",
},
);
expect(resolveRemoteHostIdMock).toHaveBeenCalledWith("host-sync-id");
});
it("sends an empty prompted domain for local RDP accounts", async () => {
isElectronMock.mockReturnValue(false);
await getGuacamoleTokenFromHost(9, "local", "rdp", {
username: "local-admin",
password: "secret",
domain: "",
});
expect(authApiMock.post).toHaveBeenCalledWith("/guacamole/connect-host/9", {
protocol: "rdp",
promptedUsername: "local-admin",
promptedPassword: "secret",
promptedDomain: "",
});
});
// Support#1240: a host set to "This Device" is served by the embedded
// backend, so its token has to come from there too -- and its id must not
// be remapped onto a remote server that may not even be configured.
it("asks the embedded backend when the host originates locally", async () => {
isElectronMock.mockReturnValue(true);
const token = await getGuacamoleTokenFromHost(9, "local", "rdp");
expect(token.token).toBe("local-token");
expect(authApiMock.post).toHaveBeenCalledWith("/guacamole/connect-host/9", {
protocol: "rdp",
});
expect(remoteApiMock.post).not.toHaveBeenCalled();
expect(resolveRemoteHostIdMock).not.toHaveBeenCalled();
});
it("reports the embedded backend's own guacd for a local origin", async () => {
isElectronMock.mockReturnValue(true);
const status = await getGuacdStatus("local");
expect(status.guacd.status).toBe("disconnected");
expect(authApiMock.get).toHaveBeenCalledWith("/guacamole/status");
expect(remoteApiMock.get).not.toHaveBeenCalled();
});
it("uses the remote server for an explicitly remote origin", async () => {
isElectronMock.mockReturnValue(true);
await getGuacamoleTokenFromHost(9, "remote", "rdp");
expect(remoteApiMock.post).toHaveBeenCalledOnce();
expect(authApiMock.post).not.toHaveBeenCalled();
});
it("does not fall back to a colliding local id when sync resolution fails", async () => {
isElectronMock.mockReturnValue(true);
resolveRemoteHostIdMock.mockResolvedValue(null);
await expect(
getGuacamoleTokenFromHost(
9,
"remote",
"rdp",
undefined,
"missing-sync-id",
),
).rejects.toThrow("The synced host does not exist on the remote server");
expect(remoteApiMock.post).not.toHaveBeenCalled();
});
});
it("looks up the session on the backend that issued its token", async () => {
isElectronMock.mockReturnValue(true);
const signal = new AbortController().signal;
await getGuacamoleConnectionId("connect/id", "remote", signal);
expect(remoteApiMock.get).toHaveBeenCalledWith(
"/guacamole/connection/connect%2Fid",
{ signal },
);
expect(authApiMock.get).not.toHaveBeenCalled();
});
@@ -0,0 +1,254 @@
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";
/**
* Picks the backend that will actually serve this session, so the token and
* status come from the same place as the connection.
*
* Outside Electron there is only one backend. Inside it, a host left on
* Default still resolves to the connected server -- asking the embedded
* backend would report the guacd *it* can reach rather than the one serving
* the session. A host explicitly set to "This Device" (Support#1240) is
* served by the embedded backend against the user's own guacd, so its calls
* have to go there instead.
*
* Callers pass the origin explicitly rather than defaulting it: a default
* 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();
}
export interface GuacamoleTokenRequest {
protocol: "rdp" | "vnc" | "telnet";
hostname: string;
port?: number;
username?: string;
password?: string;
domain?: string;
security?: string;
ignoreCert?: boolean;
guacamoleConfig?: GuacamoleConfig;
}
export interface GuacamoleTokenResponse {
token: string;
guacamoleConnectionId?: string | null;
termixConnectId?: string;
}
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 {
const config = source?.guacamoleConfig;
if (!config) return undefined;
let dpi: unknown;
if (typeof config === "string") {
try {
dpi = JSON.parse(config).dpi;
} catch {
return undefined;
}
} else {
dpi = config.dpi;
}
const parsedDpi = typeof dpi === "string" ? Number(dpi) : dpi;
if (
typeof parsedDpi !== "number" ||
!Number.isFinite(parsedDpi) ||
parsedDpi <= 0
) {
return undefined;
}
return Math.trunc(parsedDpi);
}
function toGuacamoleParams(
config: GuacamoleTokenRequest["guacamoleConfig"],
): Record<string, unknown> {
if (!config) return {};
const params: Record<string, unknown> = {};
const mappings: Record<string, string> = {
colorDepth: "color-depth",
resizeMethod: "resize-method",
forceLossless: "force-lossless",
disableAudio: "disable-audio",
enableAudioInput: "enable-audio-input",
enableWallpaper: "enable-wallpaper",
enableTheming: "enable-theming",
enableFontSmoothing: "enable-font-smoothing",
enableFullWindowDrag: "enable-full-window-drag",
enableDesktopComposition: "enable-desktop-composition",
enableMenuAnimations: "enable-menu-animations",
disableBitmapCaching: "disable-bitmap-caching",
disableOffscreenCaching: "disable-offscreen-caching",
disableGlyphCaching: "disable-glyph-caching",
disableGfx: "disable-gfx",
enablePrinting: "enable-printing",
printerName: "printer-name",
enableDrive: "enable-drive",
driveName: "drive-name",
drivePath: "drive-path",
createDrivePath: "create-drive-path",
disableDownload: "disable-download",
disableUpload: "disable-upload",
enableTouch: "enable-touch",
clientName: "client-name",
initialProgram: "initial-program",
serverLayout: "server-layout",
gatewayHostname: "gateway-hostname",
gatewayPort: "gateway-port",
gatewayUsername: "gateway-username",
gatewayPassword: "gateway-password",
gatewayDomain: "gateway-domain",
remoteApp: "remote-app",
remoteAppDir: "remote-app-dir",
remoteAppArgs: "remote-app-args",
normalizeClipboard: "normalize-clipboard",
disableCopy: "disable-copy",
disablePaste: "disable-paste",
swapRedBlue: "swap-red-blue",
readOnly: "read-only",
recordingPath: "recording-path",
recordingName: "recording-name",
createRecordingPath: "create-recording-path",
recordingExcludeOutput: "recording-exclude-output",
recordingExcludeMouse: "recording-exclude-mouse",
recordingIncludeKeys: "recording-include-keys",
wolSendPacket: "wol-send-packet",
wolMacAddr: "wol-mac-addr",
wolBroadcastAddr: "wol-broadcast-addr",
wolUdpPort: "wol-udp-port",
wolWaitTime: "wol-wait-time",
};
for (const [key, value] of Object.entries(config)) {
if (value !== undefined && value !== null && value !== "") {
const paramName = mappings[key] || key;
if (typeof value === "boolean") {
params[paramName] = value ? "true" : "false";
} else {
params[paramName] = value;
}
}
}
return params;
}
export async function getGuacamoleToken(
request: GuacamoleTokenRequest,
origin: ConnectionOrigin,
): Promise<GuacamoleTokenResponse> {
try {
const guacParams = toGuacamoleParams(request.guacamoleConfig);
const response = await guacamoleApi(origin).post("/guacamole/token", {
type: request.protocol,
hostname: request.hostname,
port: request.port,
username: request.username,
password: request.password,
domain: request.domain,
security: request.security,
"ignore-cert": request.ignoreCert,
...guacParams,
});
return response.data;
} catch (error) {
throw handleApiError(error, "get guacamole token");
}
}
export async function getGuacamoleTokenFromHost(
hostId: number,
origin: ConnectionOrigin,
protocol?: "rdp" | "vnc" | "telnet",
promptedCredentials?: {
username?: string;
password?: string;
domain?: string;
},
syncId?: string | null,
): Promise<GuacamoleTokenResponse> {
try {
// A locally-originated session is served by the embedded backend, which
// knows the host by its local id -- remapping it onto the remote
// server's id would address the wrong row, or fail outright when no
// server is configured at all.
const useRemote = isElectron() && origin === "remote";
const remoteHostId = useRemote ? await resolveRemoteHostId(syncId) : null;
if (useRemote && syncId && remoteHostId === null) {
throw new Error("The synced host does not exist on the remote server");
}
const targetHostId = remoteHostId ?? hostId;
const response = await guacamoleApi(origin).post(
`/guacamole/connect-host/${targetHostId}`,
{
...(protocol ? { protocol } : {}),
...(promptedCredentials?.username
? { promptedUsername: promptedCredentials.username }
: {}),
...(promptedCredentials?.password
? { promptedPassword: promptedCredentials.password }
: {}),
...(promptedCredentials
? { promptedDomain: promptedCredentials.domain ?? "" }
: {}),
},
);
return response.data;
} catch (error) {
throw handleApiError(error, "get guacamole token from host");
}
}
export async function getGuacdStatus(origin: ConnectionOrigin): Promise<{
guacd: { status: string };
}> {
const response = await guacamoleApi(origin).get("/guacamole/status");
return response.data;
}
export async function getGuacamoleConnectionId(
connectId: string,
origin: ConnectionOrigin,
signal?: AbortSignal,
): Promise<string | null> {
const response = await guacamoleApi(origin).get(
`/guacamole/connection/${encodeURIComponent(connectId)}`,
{ signal },
);
return response.data.guacamoleConnectionId ?? null;
}
@@ -0,0 +1,63 @@
import { describe, expect, it, vi } from "vitest";
import {
isPasteShortcut,
pasteTextToRemote,
type GuacamoleClipboardClient,
} from "./guacamole-clipboard.js";
describe("Guacamole clipboard paste", () => {
it("recognizes Ctrl+V and Command+V without intercepting Alt+V", () => {
expect(
isPasteShortcut({
key: "v",
ctrlKey: true,
metaKey: false,
altKey: false,
}),
).toBe(true);
expect(
isPasteShortcut({
key: "V",
ctrlKey: false,
metaKey: true,
altKey: false,
}),
).toBe(true);
expect(
isPasteShortcut({
key: "v",
ctrlKey: true,
metaKey: false,
altKey: true,
}),
).toBe(false);
});
it("updates the remote clipboard before sending Ctrl+V", () => {
const events: string[] = [];
const client: GuacamoleClipboardClient = {
createClipboardStream: vi.fn((mimetype: string) => {
events.push(`stream:${mimetype}`);
return {
sendBlob: () => events.push("blob"),
sendEnd: () => events.push("end"),
};
}),
sendKeyEvent: vi.fn((pressed: number, keysym: number) => {
events.push(`key:${pressed}:${keysym}`);
}),
};
pasteTextToRemote(client, "Firefox clipboard");
expect(events).toEqual([
"stream:text/plain",
"blob",
"end",
"key:1:65507",
"key:1:118",
"key:0:118",
"key:0:65507",
]);
});
});
@@ -0,0 +1,39 @@
import Guacamole from "guacamole-common-js";
const CONTROL_LEFT_KEYSYM = 0xffe3;
const V_KEYSYM = 0x76;
interface ClipboardOutputStream {
sendBlob(data: string): void;
sendEnd(): void;
}
export interface GuacamoleClipboardClient {
createClipboardStream(mimetype: string): ClipboardOutputStream;
sendKeyEvent(pressed: number, keysym: number): void;
}
export function isPasteShortcut(
event: Pick<KeyboardEvent, "altKey" | "ctrlKey" | "key" | "metaKey">,
): boolean {
return (
!event.altKey &&
(event.ctrlKey || event.metaKey) &&
event.key.toLowerCase() === "v"
);
}
export function pasteTextToRemote(
client: GuacamoleClipboardClient,
text: string,
): void {
const stream = client.createClipboardStream("text/plain");
const writer = new Guacamole.StringWriter(stream);
writer.sendText(text);
writer.sendEnd();
client.sendKeyEvent(1, CONTROL_LEFT_KEYSYM);
client.sendKeyEvent(1, V_KEYSYM);
client.sendKeyEvent(0, V_KEYSYM);
client.sendKeyEvent(0, CONTROL_LEFT_KEYSYM);
}
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import {
getGuacamoleDisplaySize,
readConfiguredDimension,
} from "./guacamole-display-size";
describe("getGuacamoleDisplaySize", () => {
it("requests native pixels and matching DPI for HiDPI RDP", () => {
expect(getGuacamoleDisplaySize(1280, 720, "rdp", 2)).toEqual({
width: 2560,
height: 1440,
dpi: 192,
pixelRatio: 2,
});
});
it("scales a configured RDP DPI with the device pixel ratio", () => {
expect(getGuacamoleDisplaySize(1000, 600, "rdp", 1.5, 120)).toEqual({
width: 1500,
height: 900,
dpi: 180,
pixelRatio: 1.5,
});
});
it("leaves non-RDP protocols at CSS-pixel dimensions", () => {
expect(getGuacamoleDisplaySize(1280, 720, "vnc", 2)).toEqual({
width: 1280,
height: 720,
pixelRatio: 1,
});
});
it("caps pathological pixel ratios to protect remote session size", () => {
expect(getGuacamoleDisplaySize(400, 800, "rdp", 4)).toEqual({
width: 1200,
height: 2400,
dpi: 288,
pixelRatio: 3,
});
});
it("uses a configured resolution in place of the container size", () => {
expect(
getGuacamoleDisplaySize(800, 600, "rdp", 1, undefined),
).toMatchObject({ width: 800, height: 600 });
expect(
getGuacamoleDisplaySize(1920, 1080, "rdp", 1, undefined),
).toMatchObject({ width: 1920, height: 1080 });
});
});
describe("readConfiguredDimension", () => {
it("accepts the strings the host editor stores", () => {
expect(readConfiguredDimension("1920")).toBe(1920);
expect(readConfiguredDimension(1080)).toBe(1080);
});
it("treats an unset or unusable value as 'follow the container'", () => {
expect(readConfiguredDimension("")).toBeUndefined();
expect(readConfiguredDimension(undefined)).toBeUndefined();
expect(readConfiguredDimension(null)).toBeUndefined();
expect(readConfiguredDimension("auto")).toBeUndefined();
expect(readConfiguredDimension("0")).toBeUndefined();
expect(readConfiguredDimension("-1080")).toBeUndefined();
});
});
@@ -0,0 +1,48 @@
const DEFAULT_RDP_DPI = 96;
const MAX_DEVICE_PIXEL_RATIO = 3;
/**
* Reads a guacamoleConfig display field. The UI stores these as strings, and
* leaves them empty when the size should follow the browser window.
*/
export function readConfiguredDimension(value: unknown): number | undefined {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
}
export interface GuacamoleDisplaySize {
width: number;
height: number;
dpi?: number;
pixelRatio: number;
}
export function getGuacamoleDisplaySize(
cssWidth: number,
cssHeight: number,
protocol: string | undefined,
devicePixelRatio: number,
configuredDpi?: number,
): GuacamoleDisplaySize {
const isRdp = protocol === "rdp";
const pixelRatio = isRdp
? Math.min(
MAX_DEVICE_PIXEL_RATIO,
Math.max(1, Number.isFinite(devicePixelRatio) ? devicePixelRatio : 1),
)
: 1;
const size = {
width: Math.max(1, Math.round(cssWidth * pixelRatio)),
height: Math.max(1, Math.round(cssHeight * pixelRatio)),
pixelRatio,
};
if (!isRdp) return size;
const baseDpi =
configuredDpi && Number.isFinite(configuredDpi) && configuredDpi > 0
? configuredDpi
: DEFAULT_RDP_DPI;
return { ...size, dpi: Math.round(baseDpi * pixelRatio) };
}
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import {
canUploadToRdpDrive,
getFileDropDisposition,
hasDraggedFiles,
} from "./guacamole-file-drop.ts";
describe("Guacamole file drop", () => {
it("recognizes external file drags", () => {
expect(hasDraggedFiles(["Files"])).toBe(true);
expect(hasDraggedFiles(["text/plain"])).toBe(false);
});
it("rejects files when upload is unavailable instead of ignoring them", () => {
expect(getFileDropDisposition(["Files"], 1, false)).toBe("reject");
expect(getFileDropDisposition(["Files"], 1, true)).toBe("upload");
});
it("accepts an enabled RDP drive before a filesystem object is advertised", () => {
expect(canUploadToRdpDrive(true, true, false)).toBe(true);
expect(canUploadToRdpDrive(true, false, true)).toBe(true);
expect(canUploadToRdpDrive(true, false, false)).toBe(false);
expect(canUploadToRdpDrive(false, true, true)).toBe(false);
});
});
@@ -0,0 +1,22 @@
export type FileDropDisposition = "ignore" | "reject" | "upload";
export function hasDraggedFiles(types: readonly string[]): boolean {
return types.includes("Files");
}
export function getFileDropDisposition(
types: readonly string[],
fileCount: number,
canUpload: boolean,
): FileDropDisposition {
if (!hasDraggedFiles(types) || fileCount === 0) return "ignore";
return canUpload ? "upload" : "reject";
}
export function canUploadToRdpDrive(
uploadAllowed: boolean,
driveEnabled: boolean,
hasFilesystem: boolean,
): boolean {
return uploadAllowed && (driveEnabled || hasFilesystem);
}
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { describeUploadError } from "./guacamole-filesystem";
const t = (key: string) => `#${key}`;
describe("describeUploadError", () => {
it("explains guacd's refusal to open the target file as a drive-folder problem", () => {
expect(describeUploadError(new Error("FAIL (CANNOT OPEN)"), t)).toBe(
"#driveNotWritable",
);
expect(describeUploadError(new Error("FAIL (NO FS)"), t)).toBe(
"#driveUnavailable",
);
});
it("passes other messages through and falls back for unknown errors", () => {
expect(describeUploadError(new Error("disk full"), t)).toBe("disk full");
expect(describeUploadError(undefined, t)).toBe("#uploadFailed");
});
});
@@ -0,0 +1,223 @@
import Guacamole from "guacamole-common-js";
// The bundled Guacamole runtime exposes Client.createFileStream(), but the
// package's older TypeScript declaration omits it.
export interface GuacamoleFileStreamClient {
createFileStream(mimetype: string, filename: string): Guacamole.OutputStream;
}
// The root stream of a Guacamole.Object maps stream name to mimetype; a stream
// carrying that same mimetype is itself a directory.
export const STREAM_INDEX_MIMETYPE =
"application/vnd.glyptodon.guacamole.stream-index+json";
export interface RemoteFileEntry {
name: string;
path: string;
mimetype: string;
isDirectory: boolean;
}
export function isDirectoryMimetype(mimetype: string): boolean {
return mimetype === STREAM_INDEX_MIMETYPE;
}
export function joinPath(parent: string, name: string): string {
return parent === "/" ? `/${name}` : `${parent}/${name}`;
}
export function parentPath(path: string): string {
const cut = path.lastIndexOf("/");
return cut <= 0 ? "/" : path.slice(0, cut);
}
export function basename(path: string): string {
return path.slice(path.lastIndexOf("/") + 1) || path;
}
// guacd returns absolute paths as keys, but a malformed or relative key would
// otherwise produce a broken path on the next descent.
export function parseDirectoryIndex(
json: string,
path: string,
): RemoteFileEntry[] {
const index = JSON.parse(json) as Record<string, string>;
return Object.entries(index)
.map(([key, mimetype]) => {
const name = basename(key);
return {
name,
path: key.startsWith("/") ? key : joinPath(path, name),
mimetype,
isDirectory: isDirectoryMimetype(mimetype),
};
})
.sort((a, b) =>
a.isDirectory === b.isDirectory
? a.name.localeCompare(b.name)
: a.isDirectory
? -1
: 1,
);
}
// A refused stream is answered with an error ack that never reaches the body
// callback, so every request needs its own deadline to avoid hanging forever.
const REQUEST_TIMEOUT_MS = 15000;
function requestStream(
filesystem: Guacamole.Object,
path: string,
): Promise<{ stream: Guacamole.InputStream; mimetype: string }> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error(`Timed out reading ${path}`)),
REQUEST_TIMEOUT_MS,
);
filesystem.requestInputStream(path, (stream, mimetype) => {
clearTimeout(timeout);
resolve({ stream, mimetype });
});
});
}
export async function listDirectory(
filesystem: Guacamole.Object,
path: string,
): Promise<RemoteFileEntry[]> {
const { stream, mimetype } = await requestStream(filesystem, path);
if (!isDirectoryMimetype(mimetype)) {
throw new Error(`${path} is not a directory`);
}
const json = await new Promise<string>((resolve) => {
const reader = new Guacamole.StringReader(stream);
let text = "";
reader.ontext = (chunk: string) => {
text += chunk;
};
reader.onend = () => resolve(text);
});
return parseDirectoryIndex(json, path);
}
export async function downloadFile(
filesystem: Guacamole.Object,
path: string,
): Promise<{ blob: Blob; filename: string }> {
const { stream, mimetype } = await requestStream(filesystem, path);
if (isDirectoryMimetype(mimetype)) {
throw new Error(`${path} is a directory`);
}
const blob = await new Promise<Blob>((resolve) => {
const reader = new Guacamole.BlobReader(stream, mimetype);
reader.onend = () => resolve(reader.getBlob());
});
return { blob, filename: basename(path) };
}
export function uploadFile(
filesystem: Guacamole.Object,
directory: string,
file: File,
onProgress?: (sent: number, total: number) => void,
): Promise<void> {
return writeUpload(
filesystem.createOutputStream(
file.type || "application/octet-stream",
joinPath(directory, file.name),
),
file,
onProgress,
);
}
/**
* Uploads through Guacamole's connection-level file stream. RDP exposes this
* path even when guacd does not advertise a browsable filesystem object, so
* display drops must not depend on `Client.onfilesystem` having fired.
*/
export function uploadFileToClient(
client: GuacamoleFileStreamClient,
file: File,
onProgress?: (sent: number, total: number) => void,
): Promise<void> {
return writeUpload(createClientFileStream(client, file), file, onProgress);
}
export function createClientFileStream(
client: GuacamoleFileStreamClient,
file: File,
): Guacamole.OutputStream {
return client.createFileStream(
file.type || "application/octet-stream",
file.name,
);
}
function writeUpload(
stream: Guacamole.OutputStream,
file: File,
onProgress?: (sent: number, total: number) => void,
): Promise<void> {
return new Promise((resolve, reject) => {
const writer = new Guacamole.BlobWriter(stream);
// A rejected blob stops the writer without firing onerror or oncomplete —
// only the error ack reports it, so without this the upload hangs forever.
writer.onack = (status: Guacamole.Status) => {
if (status.isError()) {
reject(
new Error(
status.message || `Server refused the upload of ${file.name}`,
),
);
}
};
writer.onerror = () => reject(new Error(`Failed to upload ${file.name}`));
writer.onprogress = (_blob: Blob, offset: number) =>
onProgress?.(offset, file.size);
writer.oncomplete = () => {
writer.sendEnd();
onProgress?.(file.size, file.size);
resolve();
};
writer.sendBlob(file);
});
}
export function saveBlobAs(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}
/**
* Turns an upload failure into something a user can act on. guacd answers a
* refused stream with an ack like "FAIL (CANNOT OPEN)" - accurate, but it
* means "the drive folder isn't writable", which is what people need to hear.
*/
export function describeUploadError(
error: unknown,
t: (key: "driveNotWritable" | "driveUnavailable" | "uploadFailed") => string,
): string {
const message = error instanceof Error ? error.message : "";
if (/cannot open|can't open|permission denied/i.test(message)) {
return t("driveNotWritable");
}
if (/no fs/i.test(message)) {
return t("driveUnavailable");
}
return message || t("uploadFailed");
}
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { detectMetaKeyFamily, metaKeyLabels } from "./guacamole-meta-key.js";
describe("guacamole toolbar meta key labels", () => {
it("keeps Windows labels for RDP regardless of the local device", () => {
expect(
detectMetaKeyFamily("rdp", {
platform: "MacIntel",
userAgent: "Macintosh; Intel Mac OS X",
userAgentDataPlatform: "macOS",
}),
).toBe("win");
});
it("labels VNC with the local device's modifier instead of Windows", () => {
expect(
detectMetaKeyFamily("vnc", {
platform: "MacIntel",
userAgent: "Macintosh; Intel Mac OS X",
userAgentDataPlatform: "macOS",
}),
).toBe("cmd");
expect(
detectMetaKeyFamily("vnc", {
platform: "Linux x86_64",
userAgent: "X11; Linux x86_64",
userAgentDataPlatform: "Linux",
}),
).toBe("super");
expect(
detectMetaKeyFamily("vnc", {
platform: "Win32",
userAgent: "Windows NT 10.0",
userAgentDataPlatform: "Windows",
}),
).toBe("win");
});
it("exposes Super/Cmd button copy for non-Windows VNC sessions", () => {
expect(metaKeyLabels("super").short).toBe("Super");
expect(metaKeyLabels("super").lock).toBe("Super+L");
expect(metaKeyLabels("cmd").short).toBe("Cmd");
expect(metaKeyLabels("cmd").lock).toBe("Cmd+L");
});
});
@@ -0,0 +1,82 @@
export type MetaKeyFamily = "win" | "super" | "cmd";
export interface MetaKeyLabels {
family: MetaKeyFamily;
short: string;
lock: string;
stickyKey: string;
keyTooltip: string;
lockTooltip: string;
}
/**
* RDP is a Windows desktop, so the toolbar keeps Windows labels. VNC is used
* for Linux and macOS as often as Windows; branding those sessions with a
* Windows key is wrong, and the local device is the only OS we can see.
*/
export function detectMetaKeyFamily(
protocol: "rdp" | "vnc" | "telnet",
env: {
platform?: string;
userAgent?: string;
userAgentDataPlatform?: string;
} = {},
): MetaKeyFamily {
if (protocol === "rdp") return "win";
const uaPlatform = env.userAgentDataPlatform ?? "";
const platform = env.platform ?? "";
const userAgent = env.userAgent ?? "";
const haystack = `${uaPlatform} ${platform} ${userAgent}`;
if (/iphone|ipad|ipod|mac/i.test(haystack)) return "cmd";
if (/win/i.test(haystack)) return "win";
return "super";
}
export function detectRuntimeMetaKeyFamily(
protocol: "rdp" | "vnc" | "telnet",
): MetaKeyFamily {
if (typeof navigator === "undefined") {
return detectMetaKeyFamily(protocol);
}
const uaData = navigator as Navigator & {
userAgentData?: { platform?: string };
};
return detectMetaKeyFamily(protocol, {
platform: navigator.platform,
userAgent: navigator.userAgent,
userAgentDataPlatform: uaData.userAgentData?.platform,
});
}
export function metaKeyLabels(family: MetaKeyFamily): MetaKeyLabels {
if (family === "cmd") {
return {
family,
short: "Cmd",
lock: "Cmd+L",
stickyKey: "remoteDesktop.toolbar.cmd",
keyTooltip: "remoteDesktop.toolbar.cmdKey",
lockTooltip: "remoteDesktop.toolbar.cmdL",
};
}
if (family === "super") {
return {
family,
short: "Super",
lock: "Super+L",
stickyKey: "remoteDesktop.toolbar.super",
keyTooltip: "remoteDesktop.toolbar.superKey",
lockTooltip: "remoteDesktop.toolbar.superL",
};
}
return {
family,
short: "Win",
lock: "Win+L",
stickyKey: "remoteDesktop.toolbar.win",
keyTooltip: "remoteDesktop.toolbar.winKey",
lockTooltip: "remoteDesktop.toolbar.winL",
};
}
@@ -0,0 +1,82 @@
import { describe, expect, it, vi } from "vitest";
import { bindPointerInput } from "./guacamole-pointer.js";
function listenedEvents(): {
element: HTMLElement;
events: () => string[];
} {
const element = document.createElement("div");
const seen: string[] = [];
const original = element.addEventListener.bind(element);
element.addEventListener = ((type: string, ...rest: unknown[]) => {
seen.push(type);
return (original as (t: string, ...r: unknown[]) => void)(type, ...rest);
}) as typeof element.addEventListener;
return { element, events: () => seen };
}
describe("bindPointerInput", () => {
// A touchscreen laptop reports maxTouchPoints > 0 and still has a mouse.
// Binding only the touch emulator left the pointer dead on those machines.
it.each([
["no touch mode", null],
["touchscreen", "touchscreen" as const],
["touchpad", "touchpad" as const],
])("keeps the physical pointer bound with %s", (_label, touchMode) => {
const { element, events } = listenedEvents();
bindPointerInput(element, touchMode, vi.fn());
expect(events()).toContain("mousedown");
expect(events()).toContain("mousemove");
expect(events()).toContain("mouseup");
});
it("adds touch listeners only when a touch mode is selected", () => {
const withoutTouch = listenedEvents();
bindPointerInput(withoutTouch.element, null, vi.fn());
const plainTouchCount = withoutTouch
.events()
.filter((e) => e.startsWith("touch")).length;
const withTouch = listenedEvents();
bindPointerInput(withTouch.element, "touchscreen", vi.fn());
const touchModeCount = withTouch
.events()
.filter((e) => e.startsWith("touch")).length;
expect(touchModeCount).toBeGreaterThan(plainTouchCount);
});
it("does not forward a wheel flood as individual remote scroll clicks", () => {
vi.useFakeTimers();
const element = document.createElement("div");
const onState = vi.fn();
const dispose = bindPointerInput(element, null, onState);
for (let i = 0; i < 30; i++) {
element.dispatchEvent(
new WheelEvent("wheel", {
deltaY: 120,
deltaMode: 0,
bubbles: true,
cancelable: true,
}),
);
}
const presses = onState.mock.calls.filter(
([state]) => state.up === true || state.down === true,
);
expect(presses.length).toBeLessThanOrEqual(2);
vi.advanceTimersByTime(40);
const laterPresses = onState.mock.calls.filter(
([state]) => state.up === true || state.down === true,
);
expect(laterPresses.length).toBeLessThanOrEqual(4);
dispose();
vi.useRealTimers();
});
});
@@ -0,0 +1,65 @@
import Guacamole from "guacamole-common-js";
import {
createScrollCoalescer,
isScrollButtonState,
} from "./guacamole-scroll.js";
export type GuacamoleTouchMode = "touchscreen" | "touchpad";
/**
* Guacamole.Mouse listens for mousedown/mousemove/mouseup; the Touchscreen and
* Touchpad emulators listen only for touch events. The two sets do not overlap,
* so binding one instead of the other leaves that input dead.
*
* A touch-capable device is not a device without a pointer: laptops with a
* touchscreen report maxTouchPoints > 0 and are still driven by a mouse. The
* physical pointer is therefore always bound, and a touch emulator is layered
* on top when one is selected.
*/
export function bindPointerInput(
element: HTMLElement,
touchMode: GuacamoleTouchMode | null | undefined,
onState: (state: Guacamole.Mouse.State) => void,
): () => void {
const coalescer = createScrollCoalescer((state) => {
onState(state as Guacamole.Mouse.State);
});
let expectingScrollRelease = false;
const handleState = (state: Guacamole.Mouse.State) => {
coalescer.notePointer(state);
if (isScrollButtonState(state)) {
coalescer.ingestClick(state.down ? 1 : -1);
expectingScrollRelease = true;
return;
}
if (
expectingScrollRelease &&
!state.left &&
!state.middle &&
!state.right
) {
expectingScrollRelease = false;
return;
}
expectingScrollRelease = false;
onState(state);
};
const mouse = new Guacamole.Mouse(element);
mouse.onmousedown = mouse.onmouseup = mouse.onmousemove = handleState;
if (touchMode === "touchscreen") {
const touchscreen = new Guacamole.Mouse.Touchscreen(element);
touchscreen.onEach(["mousedown", "mousemove", "mouseup"], (event) =>
handleState(event.state),
);
} else if (touchMode === "touchpad") {
const touchpad = new Guacamole.Mouse.Touchpad(element);
touchpad.onEach(["mousedown", "mousemove", "mouseup"], (event) =>
handleState(event.state),
);
}
return () => coalescer.dispose();
}
@@ -0,0 +1,81 @@
import { describe, expect, it, vi } from "vitest";
import {
clampPendingScrollClicks,
createScrollCoalescer,
isScrollButtonState,
MAX_SCROLL_CLICKS_PER_FLUSH,
SCROLL_FLUSH_INTERVAL_MS,
takeScrollClicks,
} from "./guacamole-scroll.js";
describe("guacamole scroll coalescing", () => {
it("caps a click flood so a laggy tunnel cannot queue a backlog", () => {
expect(clampPendingScrollClicks(0, 40)).toBe(MAX_SCROLL_CLICKS_PER_FLUSH);
expect(clampPendingScrollClicks(0, -40)).toBe(-MAX_SCROLL_CLICKS_PER_FLUSH);
expect(takeScrollClicks(20)).toEqual({
clicks: MAX_SCROLL_CLICKS_PER_FLUSH,
direction: 1,
});
});
it("sends the first notch immediately and drops later clicks in the same burst", () => {
const send = vi.fn();
let now = 1_000;
const scheduled: Array<{ fn: () => void; ms: number }> = [];
const coalescer = createScrollCoalescer(
send,
() => now,
(fn, ms) => {
scheduled.push({ fn, ms });
return scheduled.length;
},
() => {},
);
coalescer.notePointer({
x: 12,
y: 34,
left: false,
middle: false,
right: false,
up: false,
down: false,
});
for (let i = 0; i < 20; i++) coalescer.ingestClick(1);
const firstBurst = send.mock.calls.map(([state]) => state);
expect(firstBurst).toEqual([
{
x: 12,
y: 34,
left: false,
middle: false,
right: false,
up: false,
down: true,
},
{
x: 12,
y: 34,
left: false,
middle: false,
right: false,
up: false,
down: false,
},
]);
send.mockClear();
now += SCROLL_FLUSH_INTERVAL_MS;
expect(scheduled).toHaveLength(1);
scheduled[0].fn();
expect(send.mock.calls).toHaveLength(MAX_SCROLL_CLICKS_PER_FLUSH * 2);
coalescer.dispose();
});
it("recognizes only the scroll buttons as coalesced input", () => {
expect(isScrollButtonState({ up: true, down: false })).toBe(true);
expect(isScrollButtonState({ up: false, down: true })).toBe(true);
expect(isScrollButtonState({ up: false, down: false })).toBe(false);
});
});
@@ -0,0 +1,131 @@
/**
* guacamole-common-js turns each wheel tick into one or more mouse-button
* clicks (button 4/5). High-resolution trackpads and the library's duplicate
* wheel/mousewheel listeners can emit dozens of those clicks in a single
* gesture. Each click is two Guacamole instructions, so a laggy path (a
* Cloudflare-proxied remote session especially) queues them and the remote
* desktop keeps scrolling long after the user stopped.
*
* Cap the in-flight click count and space flushes out so scrolling stays
* usable without changing the websocket proxy.
*/
export const MAX_SCROLL_CLICKS_PER_FLUSH = 2;
export const SCROLL_FLUSH_INTERVAL_MS = 32;
export type ScrollClickDirection = 1 | -1;
export interface GuacamolePointerState {
x: number;
y: number;
left: boolean;
middle: boolean;
right: boolean;
up: boolean;
down: boolean;
}
export function clampPendingScrollClicks(
pending: number,
delta: number,
max = MAX_SCROLL_CLICKS_PER_FLUSH,
): number {
return Math.max(-max, Math.min(max, pending + delta));
}
export function takeScrollClicks(pending: number): {
clicks: number;
direction: ScrollClickDirection | 0;
} {
if (pending === 0) return { clicks: 0, direction: 0 };
const direction: ScrollClickDirection = pending < 0 ? -1 : 1;
return {
clicks: Math.min(MAX_SCROLL_CLICKS_PER_FLUSH, Math.abs(pending)),
direction,
};
}
function mouseState(
x: number,
y: number,
buttons: Pick<GuacamolePointerState, "left" | "middle" | "right">,
up: boolean,
down: boolean,
): GuacamolePointerState {
return {
x,
y,
left: buttons.left,
middle: buttons.middle,
right: buttons.right,
up,
down,
};
}
export function createScrollCoalescer(
send: (state: GuacamolePointerState) => void,
now: () => number = () => Date.now(),
schedule: (fn: () => void, ms: number) => number = (fn, ms) =>
window.setTimeout(fn, ms),
cancel: (id: number) => void = (id) => window.clearTimeout(id),
): {
notePointer: (state: GuacamolePointerState) => void;
ingestClick: (direction: ScrollClickDirection) => void;
dispose: () => void;
} {
let pending = 0;
let timer: number | null = null;
let x = 0;
let y = 0;
let buttons = { left: false, middle: false, right: false };
let lastFlushAt = 0;
const flush = () => {
timer = null;
const { clicks, direction } = takeScrollClicks(pending);
pending = 0;
if (clicks === 0 || direction === 0) return;
lastFlushAt = now();
const up = direction < 0;
const down = direction > 0;
for (let i = 0; i < clicks; i++) {
send(mouseState(x, y, buttons, up, down));
send(mouseState(x, y, buttons, false, false));
}
};
return {
notePointer(state) {
x = state.x;
y = state.y;
buttons = {
left: state.left,
middle: state.middle,
right: state.right,
};
},
ingestClick(direction) {
pending = clampPendingScrollClicks(pending, direction);
const elapsed = now() - lastFlushAt;
if (elapsed >= SCROLL_FLUSH_INTERVAL_MS) {
flush();
return;
}
if (timer == null) {
timer = schedule(flush, SCROLL_FLUSH_INTERVAL_MS - elapsed);
}
},
dispose() {
if (timer == null) return;
cancel(timer);
timer = null;
},
};
}
export function isScrollButtonState(state: {
up?: boolean;
down?: boolean;
}): boolean {
return state.up === true || state.down === true;
}
@@ -0,0 +1,44 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
const fetchId = vi.hoisted(() => vi.fn());
vi.mock("./guacamole-api", () => ({ getGuacamoleConnectionId: fetchId }));
import { watchGuacamoleConnectionId } from "./guacamole-session-id";
beforeEach(() => {
vi.useFakeTimers();
fetchId.mockReset();
});
afterEach(() => vi.useRealTimers());
it("discovers the sharing ID after handshake without changing the connection origin", async () => {
fetchId.mockResolvedValueOnce(null).mockResolvedValueOnce("guacd-id");
const ready = vi.fn();
const stop = watchGuacamoleConnectionId("connect-id", "remote", ready);
await vi.advanceTimersByTimeAsync(500);
expect(fetchId).toHaveBeenLastCalledWith(
"connect-id",
"remote",
expect.any(AbortSignal),
);
expect(ready).toHaveBeenCalledExactlyOnceWith("guacd-id");
stop();
});
it("ignores a late response after disconnect or replacement", async () => {
let resolve!: (id: string) => void;
fetchId.mockReturnValue(
new Promise<string>((done) => {
resolve = done;
}),
);
const ready = vi.fn();
const stop = watchGuacamoleConnectionId("old", "local", ready);
stop();
resolve("old-guacd-id");
await vi.advanceTimersByTimeAsync(1000);
expect(ready).not.toHaveBeenCalled();
expect(fetchId.mock.calls[0][2].aborted).toBe(true);
});
it("bounds discovery when the session never opens", async () => {
fetchId.mockResolvedValue(null);
const stop = watchGuacamoleConnectionId("missing", "local", vi.fn());
await vi.advanceTimersByTimeAsync(30000);
expect(fetchId).toHaveBeenCalledTimes(20);
stop();
});
@@ -0,0 +1,31 @@
import { getGuacamoleConnectionId } from "./guacamole-api";
import type { ConnectionOrigin } from "@/lib/connection-origin";
export function watchGuacamoleConnectionId(
connectId: string,
origin: ConnectionOrigin,
onReady: (id: string) => void,
): () => void {
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
let attempts = 0;
const poll = async () => {
try {
const id = await getGuacamoleConnectionId(
connectId,
origin,
controller.signal,
);
if (controller.signal.aborted) return;
if (id) onReady(id);
else if (++attempts < 20) timer = setTimeout(poll, 500);
} catch {
// Session sharing discovery must not interrupt the remote display.
}
};
void poll();
return () => {
controller.abort();
clearTimeout(timer);
};
}
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { buildGuacamoleWebSocketBaseUrl } from "./guacamole-websocket-url.js";
const httpsLocation = {
protocol: "https:",
host: "termix.example.com",
} as Location;
describe("buildGuacamoleWebSocketBaseUrl", () => {
it("uses the same origin guacamole websocket path in production web builds", () => {
expect(
buildGuacamoleWebSocketBaseUrl({
isDev: false,
isElectronApp: false,
isEmbeddedApp: false,
basePath: "",
location: httpsLocation,
}),
).toBe("wss://termix.example.com/guacamole/websocket/");
});
it("preserves the runtime base path for proxied web deployments", () => {
expect(
buildGuacamoleWebSocketBaseUrl({
isDev: false,
isElectronApp: false,
isEmbeddedApp: false,
basePath: "/termix",
location: httpsLocation,
}),
).toBe("wss://termix.example.com/termix/guacamole/websocket/");
});
it("keeps direct local websocket access for development and embedded electron", () => {
expect(
buildGuacamoleWebSocketBaseUrl({
isDev: true,
isElectronApp: false,
isEmbeddedApp: false,
basePath: "/termix",
location: httpsLocation,
}),
).toBe("ws://localhost:30008");
expect(
buildGuacamoleWebSocketBaseUrl({
isDev: false,
isElectronApp: true,
isEmbeddedApp: true,
basePath: "/termix",
location: httpsLocation,
}),
).toBe("ws://127.0.0.1:30008");
});
it("uses the configured remote server URL for electron remote mode", () => {
expect(
buildGuacamoleWebSocketBaseUrl({
isDev: false,
isElectronApp: true,
isEmbeddedApp: false,
configuredServerUrl: "https://termix.example.com/termix/",
basePath: "",
location: httpsLocation,
}),
).toBe("wss://termix.example.com/termix/guacamole/websocket/");
});
});
@@ -0,0 +1,31 @@
export function buildGuacamoleWebSocketBaseUrl({
isDev,
isElectronApp,
isEmbeddedApp,
configuredServerUrl,
basePath,
location,
}: {
isDev: boolean;
isElectronApp: boolean;
isEmbeddedApp: boolean;
configuredServerUrl?: string;
basePath: string;
location: Pick<Location, "protocol" | "host">;
}) {
if (isDev) return "ws://localhost:30008";
if (isElectronApp) {
if (isEmbeddedApp || !configuredServerUrl) return "ws://127.0.0.1:30008";
const wsProtocol = configuredServerUrl.startsWith("https://")
? "wss://"
: "ws://";
const wsHost = configuredServerUrl
.replace(/^https?:\/\//, "")
.replace(/\/$/, "");
return `${wsProtocol}${wsHost}/guacamole/websocket/`;
}
const wsProtocol = location.protocol === "https:" ? "wss" : "ws";
return `${wsProtocol}://${location.host}${basePath}/guacamole/websocket/`;
}
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { clampGuacamoleZoom, stepGuacamoleZoom } from "./guacamole-zoom.js";
describe("guacamole zoom", () => {
it("steps in predictable quarter increments", () => {
expect(stepGuacamoleZoom(1, 1)).toBe(1.25);
expect(stepGuacamoleZoom(1, -1)).toBe(0.75);
});
it("keeps toolbar and pinch zoom within usable bounds", () => {
expect(clampGuacamoleZoom(0.1)).toBe(0.5);
expect(clampGuacamoleZoom(8)).toBe(4);
});
});
@@ -0,0 +1,11 @@
export const MIN_GUACAMOLE_ZOOM = 0.5;
export const MAX_GUACAMOLE_ZOOM = 4;
export const GUACAMOLE_ZOOM_STEP = 0.25;
export function clampGuacamoleZoom(zoom: number): number {
return Math.min(MAX_GUACAMOLE_ZOOM, Math.max(MIN_GUACAMOLE_ZOOM, zoom));
}
export function stepGuacamoleZoom(zoom: number, direction: -1 | 1): number {
return clampGuacamoleZoom(zoom + direction * GUACAMOLE_ZOOM_STEP);
}
+28
View File
@@ -0,0 +1,28 @@
/**
* Frontend half of the remote-desktop plugin.
*
* rdp/vnc/telnet are built-in tabs owned by this plugin (see
* BUILT_IN_TABS_BY_PLUGIN in src/ui/shell/pluginLoader.ts), the same
* category ssh-terminal's "terminal" tab is in: they render through
* tabUtils.tsx's hardcoded switch rather than through registerTabComponent,
* because GuacamoleApp needs hostId/tabId/protocol/quickConnectHost/ref --
* more props than the generic { tab } plugin surface can carry. There is
* nothing to register or unregister here; isTabTypeAvailable("rdp"/"vnc"/
* "telnet") is what actually gates them off when this plugin is disabled.
*
* This file exists only so the plugin has a frontend entry point matching
* every other plugin's shape, ready for the day a real frontend loader
* calls register()/unregister() directly instead of pluginLoader.ts's
* hand-duplicated stopgap wiring.
*/
export const id = "remote-desktop";
export const tabIds = ["rdp", "vnc", "telnet"];
export async function register() {
// No-op: see module comment.
}
export async function unregister() {
// No-op: see module comment.
}
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { needsRdpCredentialPrompt } from "./rdp-credential-prompt";
describe("needsRdpCredentialPrompt", () => {
it("prompts recipients when RDP has no saved authentication", () => {
expect(
needsRdpCredentialPrompt({ protocol: "rdp", rdpAuthType: "none" }),
).toBe(true);
});
it("does not prompt when the recipient has a personal override", () => {
expect(
needsRdpCredentialPrompt({
protocol: "rdp",
rdpAuthType: "none",
authOverrides: {
rdp: { credentialId: 7, required: false, ownerAuthShared: true },
},
}),
).toBe(false);
});
it("does not prompt for other protocols", () => {
expect(
needsRdpCredentialPrompt({ protocol: "vnc", rdpAuthType: "none" }),
).toBe(false);
});
});
@@ -0,0 +1,17 @@
import type { HostAuthOverrides } from "@/types/auth-protocols";
export function needsRdpCredentialPrompt({
protocol,
rdpAuthType,
authOverrides,
}: {
protocol: "rdp" | "vnc" | "telnet";
rdpAuthType?: string;
authOverrides?: HostAuthOverrides;
}): boolean {
return (
protocol === "rdp" &&
rdpAuthType === "none" &&
!authOverrides?.rdp?.credentialId
);
}
+51
View File
@@ -0,0 +1,51 @@
{
"id": "remote-desktop",
"name": "Remote Desktop",
"version": "1.0.0",
"description": "RDP, VNC and Telnet remote desktop sessions, with clipboard, drive redirection, session recording and session sharing.",
"author": { "name": "Termix" },
"license": "MIT",
"repository": "https://github.com/Termix-SSH/Plugin-RemoteDesktop",
"category": "Terminal",
"icon": "Monitor",
"engine": { "termix": ">=2.9.0", "api": "1" },
"capabilities": {
"backend": true,
"frontend": true,
"electron": true,
"platforms": ["linux", "win32", "darwin"]
},
"permissions": ["process:transport-owner"],
"contributes": {
"tabs": [
{
"id": "rdp",
"titleKey": "hosts.tabRdp",
"icon": "Monitor",
"openFrom": ["host-context-menu", "palette"]
},
{
"id": "vnc",
"titleKey": "hosts.tabVnc",
"icon": "MousePointerClick",
"openFrom": ["host-context-menu", "palette"]
},
{
"id": "telnet",
"titleKey": "hosts.tabTelnet",
"icon": "Terminal",
"openFrom": ["host-context-menu", "palette"]
}
],
"hostCapability": [
{ "key": "enableRdp", "labelKey": "hosts.tabRdp", "editorTab": "rdp" },
{ "key": "enableVnc", "labelKey": "hosts.tabVnc", "editorTab": "vnc" },
{
"key": "enableTelnet",
"labelKey": "hosts.tabTelnet",
"editorTab": "telnet"
}
]
},
"sidecars": []
}