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

@@ -0,0 +1,113 @@
// Mounted at /guacamole. Remote Desktop ships as a first-party in-process
// plugin (plugins/remote-desktop) rather than a route file imported directly
// here, so this module keeps /guacamole/* working at its original path
// without handing the plugin a reference to the shared app instance.
//
// It also brokers the small surface collab, session-sharing and the admin
// settings route need from the plugin (join tokens, live-session lookups, a
// server restart hook). Those core modules cannot import the plugin's
// TypeScript directly -- plugins/ is compiled by tsconfig.plugins.json as a
// one-directional read of src/backend/ (see copy-bundled-plugins.cjs), and a
// core file under tsconfig.node.json's rootDir cannot statically import
// something outside it. So the plugin registers this bridge once on
// activate, and clears it on deactivate; a disabled/uninstalled plugin falls
// back to the safe defaults below (404 for the router, null for session
// lookups, a no-op restart).
//
// This is an accepted, temporary coupling -- see the plugin's own README.
// collab and session-sharing are expected to become plugins of their own
// later, at which point this becomes a real inter-plugin service reference
// instead of a bridge object living in core.
//
// The router forwards with no auth of its own: it delegates to exactly one
// first-party router, and every route in that router already applies its
// own auth middleware.
import type { Request, Response, NextFunction, Router } from "express";
import { databaseLogger } from "../../utils/logger.js";
export interface GuacamoleSessionInfo {
guacamoleConnectionId: string;
hostId: number;
ownerUserId: string;
protocol: string;
openedAt: number;
}
export interface GuacamoleBridge {
router: Router;
restart: () => Promise<void>;
createJoinToken: (guacamoleConnectionId: string, readOnly: boolean) => string;
getSessionInfo: (
guacamoleConnectionId: string,
) => GuacamoleSessionInfo | null;
}
let bridge: GuacamoleBridge | null = null;
export function registerGuacamoleBridge(next: GuacamoleBridge): void {
bridge = next;
databaseLogger.info("Registered Remote Desktop plugin bridge", {
operation: "guacamole_dispatch_register",
});
}
export function unregisterGuacamoleBridge(): void {
if (!bridge) return;
bridge = null;
databaseLogger.info("Unregistered Remote Desktop plugin bridge", {
operation: "guacamole_dispatch_register",
});
}
/**
* Restarts the plugin's guacd WebSocket server, e.g. after an admin edits
* the guacd URL setting (see user-settings-routes.ts). A no-op while the
* plugin is disabled/uninstalled -- restarting a server that does not exist
* would otherwise silently resurrect it outside the plugin lifecycle.
*/
export async function restartGuacamoleService(): Promise<void> {
if (!bridge) return;
await bridge.restart();
}
/**
* Mints a read-only/writable join token for an existing live Guacamole
* session, for collab/session-sharing to hand to an invited viewer. Throws
* when the plugin is disabled/uninstalled -- unlike the read-only lookups
* below, a caller asking to mint a join token needs to know it failed rather
* than silently getting an unusable value.
*/
export function createGuacamoleJoinToken(
guacamoleConnectionId: string,
readOnly: boolean,
): string {
if (!bridge) {
throw new Error("Remote Desktop plugin is not available");
}
return bridge.createJoinToken(guacamoleConnectionId, readOnly);
}
/**
* Looks up a live Guacamole session by guacd's own connection id. Returns
* null both when no such session exists and when the plugin is
* disabled/uninstalled -- to a caller checking "is this session live", the
* two cases mean the same thing.
*/
export function getGuacamoleSessionInfo(
guacamoleConnectionId: string,
): GuacamoleSessionInfo | null {
return bridge?.getSessionInfo(guacamoleConnectionId) ?? null;
}
export default function guacamoleDispatch(
req: Request,
res: Response,
next: NextFunction,
): void {
if (!bridge) {
res.status(404).json({ error: "Remote Desktop is not available" });
return;
}
bridge.router(req, res, next);
}
@@ -1,29 +0,0 @@
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,
};
}
@@ -1,264 +0,0 @@
import GuacamoleLite from "guacamole-lite";
import { guacLogger } from "../../utils/logger.js";
import {
GuacamoleTokenService,
type GuacamoleRecordingMetadata,
} from "./token-service.js";
import {
createCurrentSessionRecordingRepository,
getCurrentSettingValue,
} from "../../database/repositories/factory.js";
import { resolveGuacdOptions } from "../../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);
}
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 = createGuacServer();
export async function restartGuacServer(): Promise<void> {
try {
guacServer.close();
} catch (err) {
guacLogger.error("Error closing guac server during restart", err as Error);
}
guacServer = createGuacServer();
}
export { guacServer, tokenService };
@@ -1,15 +0,0 @@
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",
};
}
@@ -1,97 +0,0 @@
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();
},
};
}
@@ -1,64 +0,0 @@
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";
}
@@ -1,22 +0,0 @@
/**
* 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
@@ -1,831 +0,0 @@
import { getErrorMessage } from "../../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 "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import {
resolveRecipientSharedHostAuthentication,
type RecipientSharedHostAuthResolution,
} from "../../utils/shared-host-auth-resolver.js";
import type { AuthOverrideProtocol } from "../../../types/auth-protocols.js";
import net from "net";
import crypto from "crypto";
import path from "path";
import type { AuthenticatedRequest } from "../../../types/index.js";
import {
createCurrentHostResolutionRepository,
createCurrentSettingsRepository,
} from "../../database/repositories/factory.js";
import { resolveGuacdOptions } from "../../utils/guacd-config.js";
import { createJumpHostChain } from "../jump-host-chain.js";
import { getGuacSessionByConnectId } from "./guacamole-server.js";
import {
logAudit,
getAuditUsername,
getRequestMeta,
} from "../../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 default router;
@@ -1,251 +0,0 @@
import crypto from "crypto";
import { guacLogger } from "../../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,34 @@
import { describe, expect, it } from "vitest";
import { withDriveSettings } from "../../../../../plugins/remote-desktop/backend/drive-settings.js";
describe("withDriveSettings", () => {
it("gives each user a folder under GUACD_DRIVE_PATH and creates it", () => {
expect(
withDriveSettings({ "enable-drive": true }, "user-1", {
GUACD_DRIVE_PATH: "/termix-data/rdp-drive/",
}),
).toEqual({
"enable-drive": true,
"drive-path": "/termix-data/rdp-drive/user-1",
"create-drive-path": true,
});
});
it("falls back to /drive when the environment says nothing", () => {
expect(
withDriveSettings({ "enable-drive": true }, "user-1", {}),
).toMatchObject({ "drive-path": "/drive/user-1" });
});
it("leaves a host-chosen drive-path alone", () => {
const config = { "enable-drive": true, "drive-path": "/mnt/share" };
expect(
withDriveSettings(config, "user-1", { GUACD_DRIVE_PATH: "/x" }),
).toBe(config);
});
it("does nothing when the drive is not enabled", () => {
const config = { "enable-drive": false };
expect(withDriveSettings(config, "user-1")).toBe(config);
});
});
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { resolveJumpTunnelEndpoint } from "../../../../../plugins/remote-desktop/backend/jump-tunnel-endpoint.js";
describe("resolveJumpTunnelEndpoint", () => {
it("keeps an in-process guacd tunnel on loopback", () => {
expect(resolveJumpTunnelEndpoint("localhost")).toEqual({
bindHost: "127.0.0.1",
advertisedHost: "127.0.0.1",
});
});
it("exposes the tunnel to a separate guacd container", () => {
expect(resolveJumpTunnelEndpoint("guacd")).toEqual({
bindHost: "0.0.0.0",
advertisedHost: "termix",
});
});
it("supports a custom backend hostname for external guacd", () => {
expect(
resolveJumpTunnelEndpoint("guacd.example", "termix-backend"),
).toEqual({
bindHost: "0.0.0.0",
advertisedHost: "termix-backend",
});
});
});
@@ -0,0 +1,115 @@
import net from "net";
import { afterEach, describe, expect, it } from "vitest";
import { createMacosVncCompatibilityProxy } from "../../../../../plugins/remote-desktop/backend/macos-vnc-proxy.js";
const closers: Array<() => void> = [];
afterEach(() => {
for (const close of closers.splice(0)) close();
});
async function listen(server: net.Server): Promise<number> {
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
resolve((server.address() as net.AddressInfo).port);
});
});
}
async function read(socket: net.Socket, length: number): Promise<Buffer> {
return new Promise((resolve, reject) => {
let received = Buffer.alloc(0);
const onData = (chunk: Buffer) => {
received = Buffer.concat([received, chunk]);
if (received.length < length) return;
socket.off("data", onData);
resolve(received);
};
socket.on("data", onData);
socket.once("error", reject);
});
}
describe("createMacosVncCompatibilityProxy", () => {
it("normalizes Apple's private RFB banner and forces classic VNC auth", async () => {
let clientBanner = "";
let selectedSecurityType = 0;
const target = net.createServer((socket) => {
socket.write("RFB 003.");
socket.write("889\n");
socket.once("data", (data) => {
clientBanner = data.toString("ascii");
socket.write(Buffer.from([5, 30, 33]));
socket.write(Buffer.from([36, 2, 35]));
socket.once("data", (selection) => {
selectedSecurityType = selection[0];
socket.write("desktop-data");
});
});
});
const targetPort = await listen(target);
closers.push(() => target.close());
const proxy = await createMacosVncCompatibilityProxy({
targetHost: "127.0.0.1",
targetPort,
bindHost: "127.0.0.1",
});
closers.push(proxy.close);
const client = net.createConnection(proxy.port, "127.0.0.1");
closers.push(() => client.destroy());
expect((await read(client, 12)).subarray(0, 12).toString("ascii")).toBe(
"RFB 003.008\n",
);
client.write("RFB 003.008\n");
expect([...(await read(client, 2))]).toEqual([1, 2]);
client.write(Buffer.from([2]));
expect((await read(client, 12)).toString("ascii")).toBe("desktop-data");
expect(clientBanner).toBe("RFB 003.008\n");
expect(selectedSecurityType).toBe(2);
});
it("passes standard RFB negotiation through unchanged", async () => {
const target = net.createServer((socket) => {
socket.write("RFB 003.008\n");
socket.once("data", () => socket.write(Buffer.from([2, 30, 2])));
});
const targetPort = await listen(target);
closers.push(() => target.close());
const proxy = await createMacosVncCompatibilityProxy({
targetHost: "127.0.0.1",
targetPort,
bindHost: "127.0.0.1",
});
closers.push(proxy.close);
const client = net.createConnection(proxy.port, "127.0.0.1");
closers.push(() => client.destroy());
expect((await read(client, 12)).toString("ascii")).toBe("RFB 003.008\n");
client.write("RFB 003.008\n");
expect([...(await read(client, 3))]).toEqual([2, 30, 2]);
});
it("passes Apple's security types through when VNC auth is unavailable", async () => {
const target = net.createServer((socket) => {
socket.write("RFB 003.889\n");
socket.once("data", () => socket.write(Buffer.from([2, 30, 33])));
});
const targetPort = await listen(target);
closers.push(() => target.close());
const proxy = await createMacosVncCompatibilityProxy({
targetHost: "127.0.0.1",
targetPort,
bindHost: "127.0.0.1",
});
closers.push(proxy.close);
const client = net.createConnection(proxy.port, "127.0.0.1");
closers.push(() => client.destroy());
expect((await read(client, 12)).toString("ascii")).toBe("RFB 003.008\n");
client.write("RFB 003.008\n");
expect([...(await read(client, 3))]).toEqual([2, 30, 33]);
});
});
@@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import {
buildRdpSettings,
resolveRdpAuthTypeForConnect,
resolveRdpDomain,
} from "../../../../../plugins/remote-desktop/backend/rdp-settings.js";
describe("buildRdpSettings", () => {
it("keeps saved RDP settings authoritative over stale advanced config", () => {
expect(
buildRdpSettings({
port: 3390,
domain: "EXAMPLE",
security: "nla",
ignoreCert: true,
guacConfig: {
port: 3389,
domain: "OLD",
security: "rdp",
"ignore-cert": false,
"color-depth": 24,
},
guacdOverrides: { guacdHost: "guacd.internal" },
}),
).toEqual({
port: 3390,
domain: "EXAMPLE",
security: "nla",
"ignore-cert": true,
"color-depth": 24,
guacdHost: "guacd.internal",
});
});
it("preserves an advanced security value when no saved value exists", () => {
expect(
buildRdpSettings({
port: 3389,
ignoreCert: false,
guacConfig: { security: "tls" },
guacdOverrides: {},
}).security,
).toBe("tls");
});
it("uses the prompted domain for prompt-on-connect authentication", () => {
expect(resolveRdpDomain("none", "EXAMPLE", "OLD")).toBe("EXAMPLE");
expect(resolveRdpDomain("none", "", "OLD")).toBe("");
});
it("keeps the stored domain for saved authentication", () => {
expect(resolveRdpDomain("direct", "EXAMPLE", "SAVED")).toBe("SAVED");
expect(resolveRdpDomain("none", undefined, "SAVED")).toBe("SAVED");
});
});
describe("resolveRdpAuthTypeForConnect", () => {
it("keeps prompt-on-connect authentication for a secretless recipient", () => {
expect(
resolveRdpAuthTypeForConnect({
storedAuthType: "none",
sharedResolution: { source: "secretless" },
}),
).toBe("none");
});
it("uses a recipient override instead of prompting", () => {
expect(
resolveRdpAuthTypeForConnect({
storedAuthType: "none",
sharedResolution: { source: "personal-override" },
}),
).toBe("credential");
});
it("uses authentication shared by the owner", () => {
expect(
resolveRdpAuthTypeForConnect({
storedAuthType: "credential",
sharedResolution: { source: "owner-shared", authType: "credential" },
}),
).toBe("credential");
});
});
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { withRecordingSettings } from "../../../../../plugins/remote-desktop/backend/recording-settings.js";
const PATH = "/app/data/session_recordings/guacamole";
const NAME = "b7e6c0f2-0000-4000-8000-000000000000.guac";
describe("withRecordingSettings", () => {
it("takes ownership of the location and filename", () => {
const merged = withRecordingSettings(
{
"recording-path": "/var/lib/termix/recordings",
"recording-name": "${GUAC_USERNAME}-${GUAC_DATE}",
"create-recording-path": false,
},
PATH,
NAME,
);
expect(merged).toMatchObject({
"recording-path": PATH,
"recording-name": NAME,
"create-recording-path": true,
});
});
it("defaults the content flags when the host has no opinion", () => {
expect(withRecordingSettings({}, PATH, NAME)).toMatchObject({
"recording-exclude-output": false,
"recording-include-keys": true,
});
});
it("keeps the host's content flags, including the falsy ones", () => {
const merged = withRecordingSettings(
{
"recording-exclude-output": true,
"recording-include-keys": false,
},
PATH,
NAME,
);
expect(merged).toMatchObject({
"recording-exclude-output": true,
"recording-include-keys": false,
});
});
it("leaves unrelated settings alone", () => {
const merged = withRecordingSettings(
{ "recording-exclude-mouse": true, width: "1920" },
PATH,
NAME,
);
expect(merged).toMatchObject({
"recording-exclude-mouse": true,
width: "1920",
});
});
it("does not mutate the settings it was given", () => {
const original = { "recording-path": "/tmp/mine" };
withRecordingSettings(original, PATH, NAME);
expect(original).toEqual({ "recording-path": "/tmp/mine" });
});
});
@@ -0,0 +1,104 @@
import { readFileSync } from "node:fs";
import { runInNewContext } from "node:vm";
import ts from "typescript";
import { expect, it, vi } from "vitest";
it("returns the minted token without waiting for a connection that needs that token", async () => {
const source = readFileSync(
new URL(
"../../../../../plugins/remote-desktop/backend/routes.ts",
import.meta.url,
),
"utf8",
);
const ast = ts.createSourceFile(
"routes.ts",
source,
ts.ScriptTarget.Latest,
true,
);
const route = ast.statements.find(
(s) =>
ts.isExpressionStatement(s) &&
ts.isCallExpression(s.expression) &&
s.expression.arguments[0]?.getText(ast) === '"/connect-host/:hostId"',
) as ts.ExpressionStatement;
const callback = (route.expression as ts.CallExpression)
.arguments[1] as ts.ArrowFunction;
const block = (callback.body as ts.Block).statements[0] as ts.TryStatement;
const index = block.tryBlock.statements
.map((s, i) => (ts.isSwitchStatement(s) ? i : -1))
.filter((i) => i >= 0)
.at(-1)!;
expect(index).toBeGreaterThan(-1);
const tail = block.tryBlock.statements
.slice(index + 1)
.map((s) => s.getText(ast))
.join("\n");
const json = vi.fn();
const wait = vi.fn(() => {
throw new Error("Client has not received the token yet");
});
await runInNewContext(
ts.transpileModule(`(async () => { ${tail} })()`, {
compilerOptions: { target: ts.ScriptTarget.ES2022 },
}).outputText,
{
waitForGuacdOpen: wait,
token: "minted-token",
termixConnectId: "correlation-id",
req: {},
getRequestMeta: () => ({}),
logAudit: async () => {},
getAuditUsername: async () => "owner",
userId: "owner",
connectionType: "rdp",
hostId: 7,
hostname: "host",
port: 3389,
res: { json },
},
);
expect(wait).not.toHaveBeenCalled();
expect(json).toHaveBeenCalledWith({
token: "minted-token",
termixConnectId: "correlation-id",
guacamoleConnectionId: null,
});
});
it("only reveals the live session ID to its owner", () => {
const source = readFileSync(
new URL(
"../../../../../plugins/remote-desktop/backend/guacamole-server.ts",
import.meta.url,
),
"utf8",
);
const ast = ts.createSourceFile(
"server.ts",
source,
ts.ScriptTarget.Latest,
true,
);
const fn = ast.statements.find(
(s) =>
ts.isFunctionDeclaration(s) &&
s.name?.text === "getGuacSessionByConnectId",
)!;
const info = { ownerUserId: "owner", guacamoleConnectionId: "guacd-id" };
const context = {
exports: {} as Record<string, (id: string, user: string) => unknown>,
guacSessionByConnectId: new Map([["connect-id", info]]),
};
runInNewContext(
ts.transpileModule(fn.getText(ast), {
compilerOptions: { module: ts.ModuleKind.CommonJS },
}).outputText,
context,
);
const lookup = context.exports.getGuacSessionByConnectId;
expect(lookup("connect-id", "owner")).toEqual(info);
expect(lookup("connect-id", "other")).toBeNull();
expect(lookup("pending", "owner")).toBeNull();
});
@@ -0,0 +1,105 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("../../../../../src/backend/utils/logger.js", () => ({
guacLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
}));
const { GuacamoleTokenService } =
await import("../../../../../plugins/remote-desktop/backend/token-service.js");
describe("GuacamoleTokenService", () => {
const tokenService = GuacamoleTokenService.getInstance();
it("disables RDP pre-authentication when no credentials are configured", () => {
const token = tokenService.createRdpToken("windows.example.test", "", "");
const decrypted = tokenService.decryptToken(token);
expect(decrypted?.connection.settings).toMatchObject({
hostname: "windows.example.test",
port: 3389,
"ignore-cert": true,
"disable-auth": true,
});
expect(decrypted?.connection.settings.username).toBeUndefined();
expect(decrypted?.connection.settings.password).toBeUndefined();
});
it("keeps normal RDP credential authentication unchanged", () => {
const token = tokenService.createRdpToken(
"windows.example.test",
"Administrator",
"secret",
);
const decrypted = tokenService.decryptToken(token);
expect(decrypted?.connection.settings).toMatchObject({
hostname: "windows.example.test",
username: "Administrator",
password: "secret",
port: 3389,
});
expect(decrypted?.connection.settings["disable-auth"]).toBeUndefined();
});
it("preserves recording metadata outside guacd connection settings", () => {
const recording = {
hostId: 42,
userId: "user-1",
protocol: "vnc" as const,
path: "recording.guac",
startedAt: "2026-07-14T00:00:00.000Z",
};
const token = tokenService.createVncToken(
"vnc.example.test",
"user",
"secret",
{},
recording,
);
expect(tokenService.decryptToken(token)?.recording).toEqual(recording);
});
it("preserves termixMeta through the encrypt/decrypt round trip", () => {
const termixMeta = {
termixConnectId: "connect-1",
hostId: 7,
ownerUserId: "user-1",
protocol: "rdp" as const,
};
const token = tokenService.createRdpToken(
"windows.example.test",
"Administrator",
"secret",
{},
undefined,
termixMeta,
);
expect(tokenService.decryptToken(token)?.termixMeta).toEqual(termixMeta);
});
it("createJoinToken sets connection.join, not connection.type", () => {
const token = tokenService.createJoinToken("guacd-conn-123", true);
const decrypted = tokenService.decryptToken(token);
expect(decrypted?.connection.join).toBe("guacd-conn-123");
expect(decrypted?.connection.type).toBeUndefined();
expect(decrypted?.connection.readOnly).toBe(true);
});
it("createJoinToken round-trips a read-write join through decryptToken", () => {
const token = tokenService.createJoinToken("guacd-conn-456", false);
const decrypted = tokenService.decryptToken(token);
expect(decrypted?.connection.join).toBe("guacd-conn-456");
expect(decrypted?.connection.readOnly).toBe(false);
expect(decrypted?.recording).toBeUndefined();
});
});
-254
View File
@@ -1,254 +0,0 @@
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;
}
-669
View File
@@ -1,669 +0,0 @@
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 "@/features/guacamole/GuacamoleDisplay.tsx";
import {
getGuacamoleTokenFromHost,
getGuacdStatus,
getSSHHosts,
logActivity,
isElectron,
} from "@/main-axios.ts";
import { readConfiguredDimension } from "@/features/guacamole/guacamole-display-size.ts";
import { getGuacamoleToken, parseGuacamoleConfig } from "@/api/guacamole-api";
import {
resolveConnectionOrigin,
type ConnectionOrigin,
} from "@/lib/connection-origin.ts";
import { useTranslation } from "react-i18next";
import { GuacamoleToolbar } from "@/features/guacamole/GuacamoleToolbar.tsx";
import { GuacamoleFileBrowser } from "@/features/guacamole/GuacamoleFileBrowser.tsx";
import { describeUploadError } from "@/features/guacamole/guacamole-filesystem.ts";
import { canUploadToRdpDrive } from "@/features/guacamole/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 "@/features/guacamole/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("guacamole.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("guacamole.files.uploaded", { name: file.name }));
} catch (error) {
toast.error(
describeUploadError(error, (key) =>
t(`guacamole.files.${key}`, { name: file.name }),
),
);
}
}
})();
},
[filesystem, t],
);
const handleDropUnavailable = useCallback(() => {
toast.error(
t(
allowUpload
? "guacamole.files.driveUnavailable"
: "guacamole.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("guacamole.checkingGuacd"),
});
const status = await getGuacdStatus(resolvedOrigin);
if (status.guacd.status !== "connected") {
throw new Error(t("guacamole.guacdUnavailable"));
}
addLog({
type: "info",
stage: "guac_token",
message: t("guacamole.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("guacamole.failedToConnect"));
setError(message || t("guacamole.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("guacamole.credentialPromptTitle")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("guacamole.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("guacamole.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("guacamole.connecting", {
type: (
protocol ||
hostConfig.connectionType ||
"remote"
).toUpperCase(),
})}
attempt={tokenRetry.attempt}
maxAttempts={tokenRetry.maxAttempts}
nextRetryInMs={tokenRetry.nextRetryInMs}
onManualRetry={handleReconnect}
retryLabel={t("guacamole.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("guacamole.connecting", {
type: resolvedProtocol.toUpperCase(),
})}
onManualRetry={handleReconnect}
retryLabel={t("guacamole.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("guacamole.openingSession", { type }),
});
break;
case "guac_handshake":
addLog({
type: "info",
stage,
message: t("guacamole.negotiating", { type }),
});
break;
case "guac_ready":
addLog({
type: "success",
stage,
message: t("guacamole.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;
@@ -1,967 +0,0 @@
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("guacamole.connectionError"));
onDisconnect?.();
break;
}
};
client.onerror = (error: Guacamole.Status) => {
if (!isMountedRef.current || clientRef.current !== client) return;
const errorMessage = error.message || t("guacamole.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("guacamole.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("guacamole.files.dropToUpload")}
</div>
</div>
)}
</div>
);
});
@@ -1,245 +0,0 @@
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("guacamole.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("guacamole.files.uploaded", { name: file.name }));
} catch (err) {
toast.error(
describeUploadError(err, (key) =>
t(`guacamole.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("guacamole.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("guacamole.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("guacamole.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("guacamole.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("guacamole.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("guacamole.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("guacamole.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("guacamole.files.uploading", { name: busyName })
: t("guacamole.files.upload")}
</Button>
</div>
)}
</div>
);
}
@@ -1,598 +0,0 @@
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 "@/features/guacamole/GuacamoleDisplay.tsx";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import {
detectRuntimeMetaKeyFamily,
metaKeyLabels,
type MetaKeyFamily,
} from "@/features/guacamole/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("guacamole.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("guacamole.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("guacamole.toolbar.switchToTrackpad")
: t("guacamole.toolbar.switchToTouch")}
</TooltipContent>
</Tooltip>
</>
)}
{/* Drive files — only once guacd reports a redirected filesystem */}
{hasFilesystem && onToggleFileBrowser && (
<>
<div className={SEP} />
<TipIconBtn
tooltip={t("guacamole.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("guacamole.toolbar.zoomOut")}
onClick={() => displayRef.current?.zoomOut()}
>
<ZoomOut className="size-3.5" />
</TipIconBtn>
<TipBtn
tooltip={t("guacamole.toolbar.resetZoom")}
onClick={() => displayRef.current?.resetZoom()}
className="min-w-12 tabular-nums"
>
{Math.round(zoom * 100)}%
</TipBtn>
<TipIconBtn
tooltip={t("guacamole.toolbar.zoomIn")}
onClick={() => displayRef.current?.zoomIn()}
>
<ZoomIn className="size-3.5" />
</TipIconBtn>
<TipIconBtn
tooltip={t("guacamole.toolbar.fitToScreen")}
onClick={() => displayRef.current?.resetZoom()}
>
<Scan className="size-3.5" />
</TipIconBtn>
</>
)}
{/* System combos — RDP/VNC only */}
{isRdpVnc && (
<>
<div className={SEP} />
<TipBtn
tooltip={t("guacamole.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("guacamole.toolbar.ctrl"),
],
["alt", MODIFIER_KEYSYMS.alt, t("guacamole.toolbar.alt")],
[
"shift",
MODIFIER_KEYSYMS.shift,
t("guacamole.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("guacamole.toolbar.stickyActive", { key: label })
: t("guacamole.toolbar.stickyInactive", { key: label })}
</TooltipContent>
</Tooltip>
))}
</>
)}
{/* Function key toggle */}
<div className={SEP} />
<TipBtn
tooltip={t("guacamole.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("guacamole.toolbar.esc")}
onClick={() => sendCombo(0xff1b)}
>
Esc
</TipBtn>
<TipBtn
tooltip={t("guacamole.toolbar.tab")}
onClick={() => sendCombo(0xff09)}
>
Tab
</TipBtn>
<TipBtn
tooltip={t("guacamole.toolbar.home")}
onClick={() => sendCombo(0xff50)}
>
Home
</TipBtn>
<TipBtn
tooltip={t("guacamole.toolbar.end")}
onClick={() => sendCombo(0xff57)}
>
End
</TipBtn>
<TipBtn
tooltip={t("guacamole.toolbar.pageUp")}
onClick={() => sendCombo(0xff55)}
>
PgUp
</TipBtn>
<TipBtn
tooltip={t("guacamole.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("guacamole.toolbar.arrowUp")}
onClick={() => sendCombo(0xff52)}
>
<ChevronUp className="size-3" />
</TipIconBtn>
</div>
<div className="flex">
<TipIconBtn
tooltip={t("guacamole.toolbar.arrowLeft")}
onClick={() => sendCombo(0xff51)}
>
<ChevronLeft className="size-3" />
</TipIconBtn>
<TipIconBtn
tooltip={t("guacamole.toolbar.arrowDown")}
onClick={() => sendCombo(0xff54)}
>
<ChevronDown className="size-3" />
</TipIconBtn>
<TipIconBtn
tooltip={t("guacamole.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("guacamole.toolbar.collapse")}
</TooltipContent>
</Tooltip>
{onHide && (
<TipIconBtn
tooltip={t("guacamole.toolbar.hide")}
onClick={onHide}
>
<X className="size-3.5" />
</TipIconBtn>
)}
</div>
)}
</div>
</TooltipProvider>
);
};
@@ -1,39 +0,0 @@
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);
}
@@ -1,48 +0,0 @@
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) };
}
@@ -1,22 +0,0 @@
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);
}
@@ -1,20 +0,0 @@
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");
});
});
@@ -1,223 +0,0 @@
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");
}
@@ -1,82 +0,0 @@
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: "guacamole.toolbar.cmd",
keyTooltip: "guacamole.toolbar.cmdKey",
lockTooltip: "guacamole.toolbar.cmdL",
};
}
if (family === "super") {
return {
family,
short: "Super",
lock: "Super+L",
stickyKey: "guacamole.toolbar.super",
keyTooltip: "guacamole.toolbar.superKey",
lockTooltip: "guacamole.toolbar.superL",
};
}
return {
family,
short: "Win",
lock: "Win+L",
stickyKey: "guacamole.toolbar.win",
keyTooltip: "guacamole.toolbar.winKey",
lockTooltip: "guacamole.toolbar.winL",
};
}
@@ -1,65 +0,0 @@
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();
}
@@ -1,131 +0,0 @@
/**
* 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;
}
@@ -1,31 +0,0 @@
import { getGuacamoleConnectionId } from "@/api/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);
};
}
@@ -1,31 +0,0 @@
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/`;
}
@@ -1,14 +0,0 @@
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);
});
});
@@ -1,11 +0,0 @@
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);
}
@@ -1,17 +0,0 @@
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
);
}
-173
View File
@@ -1,173 +0,0 @@
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 "../../api/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();
});
@@ -1,68 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildGuacamoleWebSocketBaseUrl } from "../../../features/guacamole/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/");
});
});
@@ -1,144 +0,0 @@
import React from "react";
import { fireEvent, render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { GuacamoleToolbar } from "../../../features/guacamole/GuacamoleToolbar.js";
import type { GuacamoleDisplayHandle } from "../../../features/guacamole/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("guacamole.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("guacamole.toolbar.zoomOut"));
fireEvent.click(getByLabelText("guacamole.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("guacamole.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();
});
});
@@ -1,63 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import {
isPasteShortcut,
pasteTextToRemote,
type GuacamoleClipboardClient,
} from "../../../features/guacamole/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",
]);
});
});
@@ -1,67 +0,0 @@
import { describe, expect, it } from "vitest";
import {
getGuacamoleDisplaySize,
readConfiguredDimension,
} from "../../../features/guacamole/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();
});
});
@@ -1,25 +0,0 @@
import { describe, expect, it } from "vitest";
import {
canUploadToRdpDrive,
getFileDropDisposition,
hasDraggedFiles,
} from "@/features/guacamole/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);
});
});
@@ -1,107 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import {
STREAM_INDEX_MIMETYPE,
basename,
isDirectoryMimetype,
joinPath,
parentPath,
parseDirectoryIndex,
createClientFileStream,
type GuacamoleFileStreamClient,
} from "../../../features/guacamole/guacamole-filesystem.js";
import type Guacamole from "guacamole-common-js";
describe("path helpers", () => {
it("joins onto the root without doubling the separator", () => {
expect(joinPath("/", "Users")).toBe("/Users");
expect(joinPath("/Users", "kei")).toBe("/Users/kei");
});
it("stops walking up at the root", () => {
expect(parentPath("/Users/kei")).toBe("/Users");
expect(parentPath("/Users")).toBe("/");
expect(parentPath("/")).toBe("/");
});
it("reads the trailing segment as the name", () => {
expect(basename("/Users/kei/notes.txt")).toBe("notes.txt");
expect(basename("notes.txt")).toBe("notes.txt");
});
});
describe("isDirectoryMimetype", () => {
it("treats only the stream index mimetype as a directory", () => {
expect(isDirectoryMimetype(STREAM_INDEX_MIMETYPE)).toBe(true);
expect(isDirectoryMimetype("text/plain")).toBe(false);
});
});
describe("parseDirectoryIndex", () => {
it("sorts directories before files and alphabetises within each group", () => {
const json = JSON.stringify({
"/report.pdf": "application/pdf",
"/apps": STREAM_INDEX_MIMETYPE,
"/notes.txt": "text/plain",
"/Documents": STREAM_INDEX_MIMETYPE,
});
expect(parseDirectoryIndex(json, "/").map((e) => e.name)).toEqual([
"apps",
"Documents",
"notes.txt",
"report.pdf",
]);
});
it("marks entries carrying the stream index mimetype as directories", () => {
const json = JSON.stringify({
"/apps": STREAM_INDEX_MIMETYPE,
"/notes.txt": "text/plain",
});
const [dir, file] = parseDirectoryIndex(json, "/");
expect(dir).toEqual({
name: "apps",
path: "/apps",
mimetype: STREAM_INDEX_MIMETYPE,
isDirectory: true,
});
expect(file.isDirectory).toBe(false);
});
it("keeps absolute keys as-is when listing a nested directory", () => {
const json = JSON.stringify({ "/Users/kei/notes.txt": "text/plain" });
expect(parseDirectoryIndex(json, "/Users/kei")[0].path).toBe(
"/Users/kei/notes.txt",
);
});
it("resolves a relative key against the directory being listed", () => {
const json = JSON.stringify({ "notes.txt": "text/plain" });
expect(parseDirectoryIndex(json, "/Users/kei")[0].path).toBe(
"/Users/kei/notes.txt",
);
});
it("returns nothing for an empty directory", () => {
expect(parseDirectoryIndex("{}", "/")).toEqual([]);
});
});
describe("direct RDP upload", () => {
it("opens a connection-level file stream when no filesystem object is available", () => {
const stream = {} as Guacamole.OutputStream;
const client = {
createFileStream: vi.fn(() => stream),
} as GuacamoleFileStreamClient;
const file = new File(["hello"], "notes.txt", { type: "text/plain" });
expect(createClientFileStream(client, file)).toBe(stream);
expect(client.createFileStream).toHaveBeenCalledWith(
"text/plain",
"notes.txt",
);
});
});
@@ -1,48 +0,0 @@
import { describe, expect, it } from "vitest";
import {
detectMetaKeyFamily,
metaKeyLabels,
} from "../../../features/guacamole/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");
});
});
@@ -1,82 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { bindPointerInput } from "../../../features/guacamole/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();
});
});
@@ -1,81 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import {
clampPendingScrollClicks,
createScrollCoalescer,
isScrollButtonState,
MAX_SCROLL_CLICKS_PER_FLUSH,
SCROLL_FLUSH_INTERVAL_MS,
takeScrollClicks,
} from "../../../features/guacamole/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);
});
});
@@ -1,44 +0,0 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
const fetchId = vi.hoisted(() => vi.fn());
vi.mock("@/api/guacamole-api", () => ({ getGuacamoleConnectionId: fetchId }));
import { watchGuacamoleConnectionId } from "../../../features/guacamole/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();
});
@@ -1,28 +0,0 @@
import { describe, expect, it } from "vitest";
import { needsRdpCredentialPrompt } from "@/features/guacamole/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);
});
});