mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
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:
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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user