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,68 @@
|
||||
# Remote Desktop
|
||||
|
||||
RDP, VNC and Telnet remote desktop sessions, packaged as a first-party plugin.
|
||||
|
||||
## Why this plugin runs in-process
|
||||
|
||||
Every other plugin runs in a `worker_threads` worker and reaches the server
|
||||
through a structured-clone `postMessage` boundary. That boundary is what makes
|
||||
the capability gate and the audit trail mean anything: a worker plugin cannot
|
||||
hold a db handle, an ssh2 `Client` or a live socket, so every privileged action
|
||||
has to go through the broker.
|
||||
|
||||
This plugin cannot work that way:
|
||||
|
||||
- it owns a `guacamole-lite` WebSocket server on port 30008,
|
||||
- its `/connect-host/:hostId` route does multi-step credential and tunnel
|
||||
resolution (SSH jump-host tunnels, a macOS VNC compatibility proxy) using
|
||||
raw `net.Socket` plumbing,
|
||||
- it needs plaintext host credentials to connect at all.
|
||||
|
||||
None of that can cross a structured-clone boundary, so this plugin runs on the
|
||||
main thread instead, under the `process:transport-owner` tier, the same as
|
||||
`ssh-terminal`, `docker` and `host-metrics`.
|
||||
|
||||
## guacd is not spawned by this plugin
|
||||
|
||||
guacd (the Apache Guacamole proxy daemon that actually speaks RDP/VNC/Telnet)
|
||||
is a separate, external service reached over plain TCP -- by default a
|
||||
sibling Docker container, or any network-reachable host configured through the
|
||||
admin "guacd URL" setting. This plugin does not download, bundle or spawn
|
||||
guacd as a child process.
|
||||
|
||||
That is a deliberate limitation, not an oversight: Apache Guacamole does not
|
||||
publish prebuilt guacd binaries for any platform (only source tarballs and
|
||||
their own Docker image), and there is no viable native guacd for Windows at
|
||||
all. Bundling guacd for Linux and macOS is possible in principle but would
|
||||
make Termix the ongoing upstream builder and security patcher for guacd plus
|
||||
FreeRDP/libvncclient/libssh2/libtelnet -- out of scope for this conversion.
|
||||
guacd's availability is handled as an external dependency with graceful
|
||||
degradation, exactly as it was before this plugin existed.
|
||||
|
||||
## What lives where
|
||||
|
||||
Unlike `ssh-terminal` (whose implementation stays in core because
|
||||
`session-manager.ts` has consumers well beyond the terminal), this plugin's
|
||||
backend and frontend code is fully relocated here from
|
||||
`src/backend/hosts/guacamole/` and `src/ui/features/guacamole/`, the same
|
||||
shape `docker` and `host-metrics` use.
|
||||
|
||||
Two pieces of shared infrastructure still reach across the plugin boundary
|
||||
into this plugin rather than living in core:
|
||||
|
||||
- `src/backend/hosts/collab/routes.ts` and
|
||||
`src/backend/hosts/session-sharing/` import `token-service.ts` and
|
||||
`guacamole-server.ts` directly to resolve and validate live Guacamole
|
||||
sessions for collaborative/shared viewing.
|
||||
- `src/ui/features/collab/` and `src/ui/features/session-sharing/` import
|
||||
`GuacamoleDisplay.tsx` directly for the same reason.
|
||||
|
||||
This is an accepted, temporary coupling: collab and session-sharing are
|
||||
expected to become plugins of their own later, at which point these become
|
||||
real inter-plugin references instead of raw relative imports.
|
||||
|
||||
## Disabling
|
||||
|
||||
Disabling behaves like any other plugin: the rdp/vnc/telnet tabs stop being
|
||||
offered from the host list, command palette and quick connect, the
|
||||
`/guacamole` router 404s, and the WebSocket server on port 30008 is closed.
|
||||
File renamed without changes.
+30
-5
@@ -1,5 +1,5 @@
|
||||
import GuacamoleLite from "guacamole-lite";
|
||||
import { guacLogger } from "../../utils/logger.js";
|
||||
import { guacLogger } from "../../../src/backend/utils/logger.js";
|
||||
import {
|
||||
GuacamoleTokenService,
|
||||
type GuacamoleRecordingMetadata,
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
import {
|
||||
createCurrentSessionRecordingRepository,
|
||||
getCurrentSettingValue,
|
||||
} from "../../database/repositories/factory.js";
|
||||
import { resolveGuacdOptions } from "../../utils/guacd-config.js";
|
||||
} from "../../../src/backend/database/repositories/factory.js";
|
||||
import { resolveGuacdOptions } from "../../../src/backend/utils/guacd-config.js";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
@@ -24,7 +24,7 @@ function readGuacdOptions(): { host: string; port: number } {
|
||||
return resolveGuacdOptions(dbUrl);
|
||||
}
|
||||
|
||||
const GUAC_WS_PORT = 30008;
|
||||
export const GUAC_WS_PORT = 30008;
|
||||
const DATA_DIR = process.env.DATA_DIR || "./db/data";
|
||||
const GUACAMOLE_RECORDINGS_DIR =
|
||||
process.env.GUACD_RECORDING_BACKEND_PATH ||
|
||||
@@ -250,9 +250,34 @@ function createGuacServer(): GuacamoleLite {
|
||||
return server;
|
||||
}
|
||||
|
||||
let guacServer = createGuacServer();
|
||||
let guacServer: GuacamoleLite | null = null;
|
||||
|
||||
/**
|
||||
* Starts the guacamole-lite WebSocket server. Called from this plugin's
|
||||
* activate() -- see plugins/remote-desktop/backend/index.mjs.
|
||||
*/
|
||||
export async function startGuacamoleService(): Promise<void> {
|
||||
guacServer = createGuacServer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the guacamole-lite WebSocket server. Called from this plugin's
|
||||
* deactivate().
|
||||
*/
|
||||
export async function stopGuacamoleService(): Promise<void> {
|
||||
if (!guacServer) return;
|
||||
try {
|
||||
guacServer.close();
|
||||
} catch (err) {
|
||||
guacLogger.error("Error closing guac server during shutdown", err as Error);
|
||||
}
|
||||
guacServer = null;
|
||||
}
|
||||
|
||||
export async function restartGuacServer(): Promise<void> {
|
||||
// A no-op while the plugin is disabled: creating a new server here would
|
||||
// silently resurrect the WS server behind deactivate()'s back.
|
||||
if (!guacServer) return;
|
||||
try {
|
||||
guacServer.close();
|
||||
} catch (err) {
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Remote Desktop (RDP/VNC/Telnet) - first-party, in-process plugin.
|
||||
*
|
||||
* Runs on the main thread rather than in a worker for the same class of
|
||||
* reason as ssh-terminal/docker (see src/backend/plugins/first-party.ts): it
|
||||
* owns a WebSocket server (guacamole-lite) on port 30008, and its
|
||||
* /connect-host/:hostId route does multi-step credential/tunnel resolution
|
||||
* (jump-host SSH tunnels, a macOS VNC compatibility proxy) involving raw
|
||||
* net.Socket plumbing the worker's structured-clone postMessage boundary
|
||||
* cannot carry.
|
||||
*
|
||||
* guacd itself is not a process this plugin spawns: it is a separate
|
||||
* external service (a sibling Docker container by default, or any
|
||||
* network-reachable guacd) reached over plain TCP. See guacd-config.ts's
|
||||
* resolveGuacdOptions(). There is nothing to supervise here - guacd's
|
||||
* availability is handled as an external dependency with graceful
|
||||
* degradation, same as before this plugin existed.
|
||||
*
|
||||
* Its backend logic is real TypeScript physically relocated here (routes.ts,
|
||||
* guacamole-server.ts, token-service.ts, etc.), compiled by
|
||||
* scripts/copy-bundled-plugins.cjs like docker's plugin backend, rather than
|
||||
* kept as a thin wrapper over code left in core -- see that script's own
|
||||
* comment for why. The one piece that stays in core is
|
||||
* src/backend/database/routes/guacamole-dispatch.ts, since it is what
|
||||
* database.ts mounts permanently at /guacamole regardless of whether this
|
||||
* plugin is installed.
|
||||
*/
|
||||
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
async function loadRelative(relativePath) {
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const target = path.join(here, `${relativePath}.js`);
|
||||
return import(new URL(`file://${target.replace(/\\/g, "/")}`).href);
|
||||
}
|
||||
|
||||
let guacServerModule = null;
|
||||
let dispatchModule = null;
|
||||
|
||||
export async function activate(ctx) {
|
||||
const [server, routes, dispatch] = await Promise.all([
|
||||
loadRelative("guacamole-server"),
|
||||
loadRelative("routes"),
|
||||
loadRelative("../../../backend/backend/database/routes/guacamole-dispatch"),
|
||||
]);
|
||||
|
||||
guacServerModule = server;
|
||||
dispatchModule = dispatch;
|
||||
|
||||
await server.startGuacamoleService();
|
||||
dispatch.registerGuacamoleBridge({
|
||||
router: routes.router,
|
||||
restart: server.restartGuacServer,
|
||||
createJoinToken: (guacamoleConnectionId, readOnly) =>
|
||||
server.tokenService.createJoinToken(guacamoleConnectionId, readOnly),
|
||||
getSessionInfo: server.getGuacSessionInfo,
|
||||
});
|
||||
|
||||
ctx.log.info(
|
||||
`Remote Desktop listening on ${server.GUAC_WS_PORT ?? 30008}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
if (dispatchModule) {
|
||||
dispatchModule.unregisterGuacamoleBridge();
|
||||
dispatchModule = null;
|
||||
}
|
||||
if (guacServerModule) {
|
||||
await guacServerModule.stopGuacamoleService();
|
||||
guacServerModule = null;
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,32 +1,32 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import { getErrorMessage } from "../../../src/backend/utils/error-message.js";
|
||||
import express from "express";
|
||||
import { GuacamoleTokenService } from "./token-service.js";
|
||||
import { withRecordingSettings } from "./recording-settings.js";
|
||||
import { withDriveSettings } from "./drive-settings.js";
|
||||
import { guacLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||
import { guacLogger } from "../../../src/backend/utils/logger.js";
|
||||
import { AuthManager } from "../../../src/backend/utils/auth-manager.js";
|
||||
import { PermissionManager } from "../../../src/backend/utils/permission-manager.js";
|
||||
import {
|
||||
resolveRecipientSharedHostAuthentication,
|
||||
type RecipientSharedHostAuthResolution,
|
||||
} from "../../utils/shared-host-auth-resolver.js";
|
||||
import type { AuthOverrideProtocol } from "../../../types/auth-protocols.js";
|
||||
} from "../../../src/backend/utils/shared-host-auth-resolver.js";
|
||||
import type { AuthOverrideProtocol } from "../../../src/types/auth-protocols.js";
|
||||
import net from "net";
|
||||
import crypto from "crypto";
|
||||
import path from "path";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { AuthenticatedRequest } from "../../../src/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";
|
||||
} from "../../../src/backend/database/repositories/factory.js";
|
||||
import { resolveGuacdOptions } from "../../../src/backend/utils/guacd-config.js";
|
||||
import { createJumpHostChain } from "../../../src/backend/hosts/jump-host-chain.js";
|
||||
import { getGuacSessionByConnectId } from "./guacamole-server.js";
|
||||
import {
|
||||
logAudit,
|
||||
getAuditUsername,
|
||||
getRequestMeta,
|
||||
} from "../../utils/audit-logger.js";
|
||||
} from "../../../src/backend/utils/audit-logger.js";
|
||||
import { resolveJumpTunnelEndpoint } from "./jump-tunnel-endpoint.js";
|
||||
import {
|
||||
buildRdpSettings,
|
||||
@@ -828,4 +828,4 @@ router.get("/status", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
export { router };
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import crypto from "crypto";
|
||||
import { guacLogger } from "../../utils/logger.js";
|
||||
import { guacLogger } from "../../../src/backend/utils/logger.js";
|
||||
|
||||
export interface GuacamoleConnectionSettings {
|
||||
type?: "rdp" | "vnc" | "telnet";
|
||||
+34
-29
@@ -1,5 +1,5 @@
|
||||
import { watchGuacamoleConnectionId } from "./guacamole-session-id";
|
||||
import { getErrorMessage } from "../../lib/error-message.js";
|
||||
import { getErrorMessage } from "@/lib/error-message.js";
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
GuacamoleDisplay,
|
||||
type GuacamoleDisplayHandle,
|
||||
type GuacamoleTouchMode,
|
||||
} from "@/features/guacamole/GuacamoleDisplay.tsx";
|
||||
} from "./GuacamoleDisplay.tsx";
|
||||
import {
|
||||
getGuacamoleTokenFromHost,
|
||||
getGuacdStatus,
|
||||
@@ -21,17 +21,17 @@ import {
|
||||
logActivity,
|
||||
isElectron,
|
||||
} from "@/main-axios.ts";
|
||||
import { readConfiguredDimension } from "@/features/guacamole/guacamole-display-size.ts";
|
||||
import { getGuacamoleToken, parseGuacamoleConfig } from "@/api/guacamole-api";
|
||||
import { readConfiguredDimension } from "./guacamole-display-size.ts";
|
||||
import { getGuacamoleToken, parseGuacamoleConfig } from "./guacamole-api";
|
||||
import {
|
||||
resolveConnectionOrigin,
|
||||
type ConnectionOrigin,
|
||||
} from "@/lib/connection-origin.ts";
|
||||
import { 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 { GuacamoleToolbar } from "./GuacamoleToolbar.tsx";
|
||||
import { GuacamoleFileBrowser } from "./GuacamoleFileBrowser.tsx";
|
||||
import { describeUploadError } from "./guacamole-filesystem.ts";
|
||||
import { canUploadToRdpDrive } from "./guacamole-file-drop.ts";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Input } from "@/components/input.tsx";
|
||||
import { PasswordInput } from "@/components/password-input.tsx";
|
||||
@@ -52,7 +52,7 @@ import { ShareSessionModal } from "@/features/session-sharing/ShareSessionModal.
|
||||
import type { SSHHost } from "@/types";
|
||||
import { useConnectionDefaults } from "@/contexts/ConnectionDefaultsContext";
|
||||
import { resolveConnectionDefaults } from "@/lib/connection-defaults";
|
||||
import { needsRdpCredentialPrompt } from "@/features/guacamole/rdp-credential-prompt";
|
||||
import { needsRdpCredentialPrompt } from "./rdp-credential-prompt";
|
||||
|
||||
interface GuacamoleAppProps {
|
||||
hostId?: string;
|
||||
@@ -139,7 +139,7 @@ const GuacamoleApp = React.forwardRef<GuacamoleAppHandle, GuacamoleAppProps>(
|
||||
<div className="relative w-full h-full">
|
||||
<ConnectionScreen
|
||||
status="disconnected"
|
||||
message={t("guacamole.hostNotFound")}
|
||||
message={t("remoteDesktop.hostNotFound")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -249,11 +249,13 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
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 }));
|
||||
toast.success(
|
||||
t("remoteDesktop.files.uploaded", { name: file.name }),
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
describeUploadError(error, (key) =>
|
||||
t(`guacamole.files.${key}`, { name: file.name }),
|
||||
t(`remoteDesktop.files.${key}`, { name: file.name }),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -267,8 +269,8 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
toast.error(
|
||||
t(
|
||||
allowUpload
|
||||
? "guacamole.files.driveUnavailable"
|
||||
: "guacamole.files.uploadDisabled",
|
||||
? "remoteDesktop.files.driveUnavailable"
|
||||
: "remoteDesktop.files.uploadDisabled",
|
||||
),
|
||||
);
|
||||
}, [allowUpload, t]);
|
||||
@@ -329,17 +331,17 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
addLog({
|
||||
type: "info",
|
||||
stage: "guac_guacd",
|
||||
message: t("guacamole.checkingGuacd"),
|
||||
message: t("remoteDesktop.checkingGuacd"),
|
||||
});
|
||||
const status = await getGuacdStatus(resolvedOrigin);
|
||||
if (status.guacd.status !== "connected") {
|
||||
throw new Error(t("guacamole.guacdUnavailable"));
|
||||
throw new Error(t("remoteDesktop.guacdUnavailable"));
|
||||
}
|
||||
|
||||
addLog({
|
||||
type: "info",
|
||||
stage: "guac_token",
|
||||
message: t("guacamole.requestingToken", {
|
||||
message: t("remoteDesktop.requestingToken", {
|
||||
type: resolvedProtocolForConnect.toUpperCase(),
|
||||
}),
|
||||
});
|
||||
@@ -408,8 +410,11 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
await fetchToken();
|
||||
tokenRetryRef.current.markConnected();
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err, t("guacamole.failedToConnect"));
|
||||
setError(message || t("guacamole.failedToConnect"));
|
||||
const message = getErrorMessage(
|
||||
err,
|
||||
t("remoteDesktop.failedToConnect"),
|
||||
);
|
||||
setError(message || t("remoteDesktop.failedToConnect"));
|
||||
addLog({ type: "error", stage: "error", message });
|
||||
tokenRetryRef.current.markFailed();
|
||||
}
|
||||
@@ -471,10 +476,10 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-bold">
|
||||
{t("guacamole.credentialPromptTitle")}
|
||||
{t("remoteDesktop.credentialPromptTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs text-muted-foreground">
|
||||
{t("guacamole.credentialPromptDescription")}
|
||||
{t("remoteDesktop.credentialPromptDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
@@ -523,7 +528,7 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 mt-2">
|
||||
<Button type="submit" variant="outline">
|
||||
{t("guacamole.connect")}
|
||||
{t("remoteDesktop.connect")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -537,7 +542,7 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
<div className="relative w-full h-full">
|
||||
<ConnectionScreen
|
||||
status={error ? tokenRetry.status : "connecting"}
|
||||
message={t("guacamole.connecting", {
|
||||
message={t("remoteDesktop.connecting", {
|
||||
type: (
|
||||
protocol ||
|
||||
hostConfig.connectionType ||
|
||||
@@ -548,7 +553,7 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
maxAttempts={tokenRetry.maxAttempts}
|
||||
nextRetryInMs={tokenRetry.nextRetryInMs}
|
||||
onManualRetry={handleReconnect}
|
||||
retryLabel={t("guacamole.retry")}
|
||||
retryLabel={t("remoteDesktop.retry")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -564,11 +569,11 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
{(!isDisplayReady || connectionError) && (
|
||||
<ConnectionScreen
|
||||
status={connectionError ? "disconnected" : "connecting"}
|
||||
message={t("guacamole.connecting", {
|
||||
message={t("remoteDesktop.connecting", {
|
||||
type: resolvedProtocol.toUpperCase(),
|
||||
})}
|
||||
onManualRetry={handleReconnect}
|
||||
retryLabel={t("guacamole.reconnect")}
|
||||
retryLabel={t("remoteDesktop.reconnect")}
|
||||
className="z-50"
|
||||
/>
|
||||
)}
|
||||
@@ -603,21 +608,21 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
addLog({
|
||||
type: "info",
|
||||
stage,
|
||||
message: t("guacamole.openingSession", { type }),
|
||||
message: t("remoteDesktop.openingSession", { type }),
|
||||
});
|
||||
break;
|
||||
case "guac_handshake":
|
||||
addLog({
|
||||
type: "info",
|
||||
stage,
|
||||
message: t("guacamole.negotiating", { type }),
|
||||
message: t("remoteDesktop.negotiating", { type }),
|
||||
});
|
||||
break;
|
||||
case "guac_ready":
|
||||
addLog({
|
||||
type: "success",
|
||||
stage,
|
||||
message: t("guacamole.sessionReady", { type }),
|
||||
message: t("remoteDesktop.sessionReady", { type }),
|
||||
});
|
||||
break;
|
||||
default:
|
||||
+5
-5
@@ -1,4 +1,4 @@
|
||||
import { getErrorMessage } from "../../lib/error-message.js";
|
||||
import { getErrorMessage } from "@/lib/error-message.js";
|
||||
import type React from "react";
|
||||
import {
|
||||
useEffect,
|
||||
@@ -613,7 +613,7 @@ export const GuacamoleDisplay = forwardRef<
|
||||
setHasError(true);
|
||||
hasKeyboardFocusRef.current = false;
|
||||
refreshKeyboardHandlers();
|
||||
onError?.(t("guacamole.connectionError"));
|
||||
onError?.(t("remoteDesktop.connectionError"));
|
||||
onDisconnect?.();
|
||||
break;
|
||||
}
|
||||
@@ -621,7 +621,7 @@ export const GuacamoleDisplay = forwardRef<
|
||||
|
||||
client.onerror = (error: Guacamole.Status) => {
|
||||
if (!isMountedRef.current || clientRef.current !== client) return;
|
||||
const errorMessage = error.message || t("guacamole.connectionError");
|
||||
const errorMessage = error.message || t("remoteDesktop.connectionError");
|
||||
setIsReady(false);
|
||||
setHasError(true);
|
||||
isConnectingRef.current = false;
|
||||
@@ -696,7 +696,7 @@ export const GuacamoleDisplay = forwardRef<
|
||||
if (!isMountedRef.current) return;
|
||||
setIsReady(false);
|
||||
setHasError(true);
|
||||
onError?.(getErrorMessage(error, t("guacamole.connectionError")));
|
||||
onError?.(getErrorMessage(error, t("remoteDesktop.connectionError")));
|
||||
}
|
||||
}, [
|
||||
getWebSocketConnection,
|
||||
@@ -958,7 +958,7 @@ export const GuacamoleDisplay = forwardRef<
|
||||
<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")}
|
||||
{t("remoteDesktop.files.dropToUpload")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
+13
-13
@@ -1,4 +1,4 @@
|
||||
import { getErrorMessage } from "../../lib/error-message.js";
|
||||
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";
|
||||
@@ -56,7 +56,7 @@ export function GuacamoleFileBrowser({
|
||||
setEntries(await listDirectory(filesystem, target));
|
||||
} catch (err) {
|
||||
setEntries([]);
|
||||
setError(getErrorMessage(err, t("guacamole.files.listFailed")));
|
||||
setError(getErrorMessage(err, t("remoteDesktop.files.listFailed")));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -74,11 +74,11 @@ export function GuacamoleFileBrowser({
|
||||
setBusyName(file.name);
|
||||
try {
|
||||
await uploadFile(filesystem, path, file);
|
||||
toast.success(t("guacamole.files.uploaded", { name: file.name }));
|
||||
toast.success(t("remoteDesktop.files.uploaded", { name: file.name }));
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
describeUploadError(err, (key) =>
|
||||
t(`guacamole.files.${key}`, { name: file.name }),
|
||||
t(`remoteDesktop.files.${key}`, { name: file.name }),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
@@ -96,7 +96,7 @@ export function GuacamoleFileBrowser({
|
||||
if (pendingUploads.length === 0) return;
|
||||
onPendingUploadsHandled();
|
||||
if (!allowUpload) {
|
||||
toast.error(t("guacamole.files.uploadDisabled"));
|
||||
toast.error(t("remoteDesktop.files.uploadDisabled"));
|
||||
return;
|
||||
}
|
||||
void upload(pendingUploads);
|
||||
@@ -112,7 +112,7 @@ export function GuacamoleFileBrowser({
|
||||
toast.error(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t("guacamole.files.downloadFailed", { name: entry.name }),
|
||||
: t("remoteDesktop.files.downloadFailed", { name: entry.name }),
|
||||
);
|
||||
} finally {
|
||||
setBusyName(null);
|
||||
@@ -125,7 +125,7 @@ export function GuacamoleFileBrowser({
|
||||
<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")}
|
||||
{t("remoteDesktop.files.title")}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -145,7 +145,7 @@ export function GuacamoleFileBrowser({
|
||||
className="size-6"
|
||||
disabled={path === "/"}
|
||||
onClick={() => setPath(parentPath(path))}
|
||||
aria-label={t("guacamole.files.parent")}
|
||||
aria-label={t("remoteDesktop.files.parent")}
|
||||
>
|
||||
<ArrowUp className="size-3.5" />
|
||||
</Button>
|
||||
@@ -157,7 +157,7 @@ export function GuacamoleFileBrowser({
|
||||
size="icon"
|
||||
className="size-6"
|
||||
onClick={() => void refresh(path)}
|
||||
aria-label={t("guacamole.files.refresh")}
|
||||
aria-label={t("remoteDesktop.files.refresh")}
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
</Button>
|
||||
@@ -174,7 +174,7 @@ export function GuacamoleFileBrowser({
|
||||
)}
|
||||
{!loading && !error && entries.length === 0 && (
|
||||
<p className="px-2 py-3 text-[11px] text-muted-foreground">
|
||||
{t("guacamole.files.empty")}
|
||||
{t("remoteDesktop.files.empty")}
|
||||
</p>
|
||||
)}
|
||||
{!loading &&
|
||||
@@ -204,7 +204,7 @@ export function GuacamoleFileBrowser({
|
||||
className="size-5 shrink-0"
|
||||
disabled={busyName === entry.name}
|
||||
onClick={() => void download(entry)}
|
||||
aria-label={t("guacamole.files.download")}
|
||||
aria-label={t("remoteDesktop.files.download")}
|
||||
>
|
||||
<Download className="size-3" />
|
||||
</Button>
|
||||
@@ -235,8 +235,8 @@ export function GuacamoleFileBrowser({
|
||||
>
|
||||
<Upload className="mr-1 size-3" />
|
||||
{busyName !== null
|
||||
? t("guacamole.files.uploading", { name: busyName })
|
||||
: t("guacamole.files.upload")}
|
||||
? t("remoteDesktop.files.uploading", { name: busyName })
|
||||
: t("remoteDesktop.files.upload")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
+6
-6
@@ -1,8 +1,8 @@
|
||||
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";
|
||||
import { GuacamoleToolbar } from "./GuacamoleToolbar.js";
|
||||
import type { GuacamoleDisplayHandle } from "./GuacamoleDisplay.js";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
@@ -49,7 +49,7 @@ describe("GuacamoleToolbar Windows key", () => {
|
||||
]);
|
||||
|
||||
sendKey.mockClear();
|
||||
fireEvent.click(getByText("guacamole.toolbar.win"));
|
||||
fireEvent.click(getByText("remoteDesktop.toolbar.win"));
|
||||
expect(sendKey).toHaveBeenCalledWith(0xffeb, true);
|
||||
});
|
||||
|
||||
@@ -75,8 +75,8 @@ describe("GuacamoleToolbar Windows key", () => {
|
||||
<GuacamoleToolbar displayRef={displayRef} protocol="vnc" zoom={1.25} />,
|
||||
);
|
||||
|
||||
fireEvent.click(getByLabelText("guacamole.toolbar.zoomOut"));
|
||||
fireEvent.click(getByLabelText("guacamole.toolbar.zoomIn"));
|
||||
fireEvent.click(getByLabelText("remoteDesktop.toolbar.zoomOut"));
|
||||
fireEvent.click(getByLabelText("remoteDesktop.toolbar.zoomIn"));
|
||||
fireEvent.click(getByText("125%"));
|
||||
expect(zoomOut).toHaveBeenCalledOnce();
|
||||
expect(zoomIn).toHaveBeenCalledOnce();
|
||||
@@ -108,7 +108,7 @@ describe("GuacamoleToolbar Windows key", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(getByLabelText("guacamole.toolbar.hide"));
|
||||
fireEvent.click(getByLabelText("remoteDesktop.toolbar.hide"));
|
||||
expect(onHide).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
+38
-30
@@ -30,14 +30,14 @@ import {
|
||||
import type {
|
||||
GuacamoleDisplayHandle,
|
||||
GuacamoleTouchMode,
|
||||
} from "@/features/guacamole/GuacamoleDisplay.tsx";
|
||||
} from "./GuacamoleDisplay.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
detectRuntimeMetaKeyFamily,
|
||||
metaKeyLabels,
|
||||
type MetaKeyFamily,
|
||||
} from "@/features/guacamole/guacamole-meta-key.ts";
|
||||
} from "./guacamole-meta-key.ts";
|
||||
|
||||
interface GuacamoleToolbarProps {
|
||||
displayRef: React.RefObject<GuacamoleDisplayHandle>;
|
||||
@@ -302,7 +302,7 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{t("guacamole.toolbar.expand")}
|
||||
{t("remoteDesktop.toolbar.expand")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
@@ -319,7 +319,7 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{t("guacamole.toolbar.dragHandle")}
|
||||
{t("remoteDesktop.toolbar.dragHandle")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -349,8 +349,8 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{touchMode === "touchscreen"
|
||||
? t("guacamole.toolbar.switchToTrackpad")
|
||||
: t("guacamole.toolbar.switchToTouch")}
|
||||
? t("remoteDesktop.toolbar.switchToTrackpad")
|
||||
: t("remoteDesktop.toolbar.switchToTouch")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
@@ -361,7 +361,7 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
|
||||
<>
|
||||
<div className={SEP} />
|
||||
<TipIconBtn
|
||||
tooltip={t("guacamole.files.title")}
|
||||
tooltip={t("remoteDesktop.files.title")}
|
||||
onClick={onToggleFileBrowser}
|
||||
className={cn(fileBrowserOpen && "bg-muted text-foreground")}
|
||||
>
|
||||
@@ -374,26 +374,26 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
|
||||
<>
|
||||
<div className={SEP} />
|
||||
<TipIconBtn
|
||||
tooltip={t("guacamole.toolbar.zoomOut")}
|
||||
tooltip={t("remoteDesktop.toolbar.zoomOut")}
|
||||
onClick={() => displayRef.current?.zoomOut()}
|
||||
>
|
||||
<ZoomOut className="size-3.5" />
|
||||
</TipIconBtn>
|
||||
<TipBtn
|
||||
tooltip={t("guacamole.toolbar.resetZoom")}
|
||||
tooltip={t("remoteDesktop.toolbar.resetZoom")}
|
||||
onClick={() => displayRef.current?.resetZoom()}
|
||||
className="min-w-12 tabular-nums"
|
||||
>
|
||||
{Math.round(zoom * 100)}%
|
||||
</TipBtn>
|
||||
<TipIconBtn
|
||||
tooltip={t("guacamole.toolbar.zoomIn")}
|
||||
tooltip={t("remoteDesktop.toolbar.zoomIn")}
|
||||
onClick={() => displayRef.current?.zoomIn()}
|
||||
>
|
||||
<ZoomIn className="size-3.5" />
|
||||
</TipIconBtn>
|
||||
<TipIconBtn
|
||||
tooltip={t("guacamole.toolbar.fitToScreen")}
|
||||
tooltip={t("remoteDesktop.toolbar.fitToScreen")}
|
||||
onClick={() => displayRef.current?.resetZoom()}
|
||||
>
|
||||
<Scan className="size-3.5" />
|
||||
@@ -406,7 +406,7 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
|
||||
<>
|
||||
<div className={SEP} />
|
||||
<TipBtn
|
||||
tooltip={t("guacamole.toolbar.ctrlAltDel")}
|
||||
tooltip={t("remoteDesktop.toolbar.ctrlAltDel")}
|
||||
onClick={() => sendCombo(0xffe3, 0xffe9, 0xffff)}
|
||||
>
|
||||
CAD
|
||||
@@ -435,13 +435,17 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
|
||||
[
|
||||
"ctrl",
|
||||
MODIFIER_KEYSYMS.ctrl,
|
||||
t("guacamole.toolbar.ctrl"),
|
||||
t("remoteDesktop.toolbar.ctrl"),
|
||||
],
|
||||
[
|
||||
"alt",
|
||||
MODIFIER_KEYSYMS.alt,
|
||||
t("remoteDesktop.toolbar.alt"),
|
||||
],
|
||||
["alt", MODIFIER_KEYSYMS.alt, t("guacamole.toolbar.alt")],
|
||||
[
|
||||
"shift",
|
||||
MODIFIER_KEYSYMS.shift,
|
||||
t("guacamole.toolbar.shift"),
|
||||
t("remoteDesktop.toolbar.shift"),
|
||||
],
|
||||
["win", MODIFIER_KEYSYMS.win, t(meta.stickyKey)],
|
||||
] as [string, number, string][]
|
||||
@@ -462,8 +466,12 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{stickyKeys[ks]
|
||||
? t("guacamole.toolbar.stickyActive", { key: label })
|
||||
: t("guacamole.toolbar.stickyInactive", { key: label })}
|
||||
? t("remoteDesktop.toolbar.stickyActive", {
|
||||
key: label,
|
||||
})
|
||||
: t("remoteDesktop.toolbar.stickyInactive", {
|
||||
key: label,
|
||||
})}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
@@ -473,7 +481,7 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
|
||||
{/* Function key toggle */}
|
||||
<div className={SEP} />
|
||||
<TipBtn
|
||||
tooltip={t("guacamole.toolbar.fnToggle")}
|
||||
tooltip={t("remoteDesktop.toolbar.fnToggle")}
|
||||
onClick={() => setShowFKeys((v) => !v)}
|
||||
className={cn(
|
||||
showFKeys &&
|
||||
@@ -498,37 +506,37 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
|
||||
{/* Navigation */}
|
||||
<div className={SEP} />
|
||||
<TipBtn
|
||||
tooltip={t("guacamole.toolbar.esc")}
|
||||
tooltip={t("remoteDesktop.toolbar.esc")}
|
||||
onClick={() => sendCombo(0xff1b)}
|
||||
>
|
||||
Esc
|
||||
</TipBtn>
|
||||
<TipBtn
|
||||
tooltip={t("guacamole.toolbar.tab")}
|
||||
tooltip={t("remoteDesktop.toolbar.tab")}
|
||||
onClick={() => sendCombo(0xff09)}
|
||||
>
|
||||
Tab
|
||||
</TipBtn>
|
||||
<TipBtn
|
||||
tooltip={t("guacamole.toolbar.home")}
|
||||
tooltip={t("remoteDesktop.toolbar.home")}
|
||||
onClick={() => sendCombo(0xff50)}
|
||||
>
|
||||
Home
|
||||
</TipBtn>
|
||||
<TipBtn
|
||||
tooltip={t("guacamole.toolbar.end")}
|
||||
tooltip={t("remoteDesktop.toolbar.end")}
|
||||
onClick={() => sendCombo(0xff57)}
|
||||
>
|
||||
End
|
||||
</TipBtn>
|
||||
<TipBtn
|
||||
tooltip={t("guacamole.toolbar.pageUp")}
|
||||
tooltip={t("remoteDesktop.toolbar.pageUp")}
|
||||
onClick={() => sendCombo(0xff55)}
|
||||
>
|
||||
PgUp
|
||||
</TipBtn>
|
||||
<TipBtn
|
||||
tooltip={t("guacamole.toolbar.pageDown")}
|
||||
tooltip={t("remoteDesktop.toolbar.pageDown")}
|
||||
onClick={() => sendCombo(0xff56)}
|
||||
>
|
||||
PgDn
|
||||
@@ -538,7 +546,7 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
|
||||
<div className="flex flex-col ml-0.5">
|
||||
<div className="flex justify-center">
|
||||
<TipIconBtn
|
||||
tooltip={t("guacamole.toolbar.arrowUp")}
|
||||
tooltip={t("remoteDesktop.toolbar.arrowUp")}
|
||||
onClick={() => sendCombo(0xff52)}
|
||||
>
|
||||
<ChevronUp className="size-3" />
|
||||
@@ -546,19 +554,19 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
|
||||
</div>
|
||||
<div className="flex">
|
||||
<TipIconBtn
|
||||
tooltip={t("guacamole.toolbar.arrowLeft")}
|
||||
tooltip={t("remoteDesktop.toolbar.arrowLeft")}
|
||||
onClick={() => sendCombo(0xff51)}
|
||||
>
|
||||
<ChevronLeft className="size-3" />
|
||||
</TipIconBtn>
|
||||
<TipIconBtn
|
||||
tooltip={t("guacamole.toolbar.arrowDown")}
|
||||
tooltip={t("remoteDesktop.toolbar.arrowDown")}
|
||||
onClick={() => sendCombo(0xff54)}
|
||||
>
|
||||
<ChevronDown className="size-3" />
|
||||
</TipIconBtn>
|
||||
<TipIconBtn
|
||||
tooltip={t("guacamole.toolbar.arrowRight")}
|
||||
tooltip={t("remoteDesktop.toolbar.arrowRight")}
|
||||
onClick={() => sendCombo(0xff53)}
|
||||
>
|
||||
<ChevronRight className="size-3" />
|
||||
@@ -579,12 +587,12 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{t("guacamole.toolbar.collapse")}
|
||||
{t("remoteDesktop.toolbar.collapse")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{onHide && (
|
||||
<TipIconBtn
|
||||
tooltip={t("guacamole.toolbar.hide")}
|
||||
tooltip={t("remoteDesktop.toolbar.hide")}
|
||||
onClick={onHide}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
+1
-1
@@ -25,7 +25,7 @@ import {
|
||||
getGuacdStatus,
|
||||
getGuacamoleConnectionId,
|
||||
getGuacamoleTokenFromHost,
|
||||
} from "../../api/guacamole-api";
|
||||
} from "./guacamole-api";
|
||||
|
||||
beforeEach(() => {
|
||||
authApiMock.get.mockClear();
|
||||
File renamed without changes.
+1
-1
@@ -3,7 +3,7 @@ import {
|
||||
isPasteShortcut,
|
||||
pasteTextToRemote,
|
||||
type GuacamoleClipboardClient,
|
||||
} from "../../../features/guacamole/guacamole-clipboard.js";
|
||||
} from "./guacamole-clipboard.js";
|
||||
|
||||
describe("Guacamole clipboard paste", () => {
|
||||
it("recognizes Ctrl+V and Command+V without intercepting Alt+V", () => {
|
||||
File renamed without changes.
+1
-1
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getGuacamoleDisplaySize,
|
||||
readConfiguredDimension,
|
||||
} from "../../../features/guacamole/guacamole-display-size";
|
||||
} from "./guacamole-display-size";
|
||||
|
||||
describe("getGuacamoleDisplaySize", () => {
|
||||
it("requests native pixels and matching DPI for HiDPI RDP", () => {
|
||||
File renamed without changes.
+1
-1
@@ -3,7 +3,7 @@ import {
|
||||
canUploadToRdpDrive,
|
||||
getFileDropDisposition,
|
||||
hasDraggedFiles,
|
||||
} from "@/features/guacamole/guacamole-file-drop.ts";
|
||||
} from "./guacamole-file-drop.ts";
|
||||
|
||||
describe("Guacamole file drop", () => {
|
||||
it("recognizes external file drags", () => {
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+1
-4
@@ -1,8 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
detectMetaKeyFamily,
|
||||
metaKeyLabels,
|
||||
} from "../../../features/guacamole/guacamole-meta-key.js";
|
||||
import { detectMetaKeyFamily, metaKeyLabels } from "./guacamole-meta-key.js";
|
||||
|
||||
describe("guacamole toolbar meta key labels", () => {
|
||||
it("keeps Windows labels for RDP regardless of the local device", () => {
|
||||
+9
-9
@@ -56,9 +56,9 @@ export function metaKeyLabels(family: MetaKeyFamily): MetaKeyLabels {
|
||||
family,
|
||||
short: "Cmd",
|
||||
lock: "Cmd+L",
|
||||
stickyKey: "guacamole.toolbar.cmd",
|
||||
keyTooltip: "guacamole.toolbar.cmdKey",
|
||||
lockTooltip: "guacamole.toolbar.cmdL",
|
||||
stickyKey: "remoteDesktop.toolbar.cmd",
|
||||
keyTooltip: "remoteDesktop.toolbar.cmdKey",
|
||||
lockTooltip: "remoteDesktop.toolbar.cmdL",
|
||||
};
|
||||
}
|
||||
if (family === "super") {
|
||||
@@ -66,17 +66,17 @@ export function metaKeyLabels(family: MetaKeyFamily): MetaKeyLabels {
|
||||
family,
|
||||
short: "Super",
|
||||
lock: "Super+L",
|
||||
stickyKey: "guacamole.toolbar.super",
|
||||
keyTooltip: "guacamole.toolbar.superKey",
|
||||
lockTooltip: "guacamole.toolbar.superL",
|
||||
stickyKey: "remoteDesktop.toolbar.super",
|
||||
keyTooltip: "remoteDesktop.toolbar.superKey",
|
||||
lockTooltip: "remoteDesktop.toolbar.superL",
|
||||
};
|
||||
}
|
||||
return {
|
||||
family,
|
||||
short: "Win",
|
||||
lock: "Win+L",
|
||||
stickyKey: "guacamole.toolbar.win",
|
||||
keyTooltip: "guacamole.toolbar.winKey",
|
||||
lockTooltip: "guacamole.toolbar.winL",
|
||||
stickyKey: "remoteDesktop.toolbar.win",
|
||||
keyTooltip: "remoteDesktop.toolbar.winKey",
|
||||
lockTooltip: "remoteDesktop.toolbar.winL",
|
||||
};
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { bindPointerInput } from "../../../features/guacamole/guacamole-pointer.js";
|
||||
import { bindPointerInput } from "./guacamole-pointer.js";
|
||||
|
||||
function listenedEvents(): {
|
||||
element: HTMLElement;
|
||||
File renamed without changes.
+1
-1
@@ -6,7 +6,7 @@ import {
|
||||
MAX_SCROLL_CLICKS_PER_FLUSH,
|
||||
SCROLL_FLUSH_INTERVAL_MS,
|
||||
takeScrollClicks,
|
||||
} from "../../../features/guacamole/guacamole-scroll.js";
|
||||
} from "./guacamole-scroll.js";
|
||||
|
||||
describe("guacamole scroll coalescing", () => {
|
||||
it("caps a click flood so a laggy tunnel cannot queue a backlog", () => {
|
||||
File renamed without changes.
+2
-2
@@ -1,7 +1,7 @@
|
||||
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";
|
||||
vi.mock("./guacamole-api", () => ({ getGuacamoleConnectionId: fetchId }));
|
||||
import { watchGuacamoleConnectionId } from "./guacamole-session-id";
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
fetchId.mockReset();
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { getGuacamoleConnectionId } from "@/api/guacamole-api";
|
||||
import { getGuacamoleConnectionId } from "./guacamole-api";
|
||||
import type { ConnectionOrigin } from "@/lib/connection-origin";
|
||||
|
||||
export function watchGuacamoleConnectionId(
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildGuacamoleWebSocketBaseUrl } from "../../../features/guacamole/guacamole-websocket-url.js";
|
||||
import { buildGuacamoleWebSocketBaseUrl } from "./guacamole-websocket-url.js";
|
||||
|
||||
const httpsLocation = {
|
||||
protocol: "https:",
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Frontend half of the remote-desktop plugin.
|
||||
*
|
||||
* rdp/vnc/telnet are built-in tabs owned by this plugin (see
|
||||
* BUILT_IN_TABS_BY_PLUGIN in src/ui/shell/pluginLoader.ts), the same
|
||||
* category ssh-terminal's "terminal" tab is in: they render through
|
||||
* tabUtils.tsx's hardcoded switch rather than through registerTabComponent,
|
||||
* because GuacamoleApp needs hostId/tabId/protocol/quickConnectHost/ref --
|
||||
* more props than the generic { tab } plugin surface can carry. There is
|
||||
* nothing to register or unregister here; isTabTypeAvailable("rdp"/"vnc"/
|
||||
* "telnet") is what actually gates them off when this plugin is disabled.
|
||||
*
|
||||
* This file exists only so the plugin has a frontend entry point matching
|
||||
* every other plugin's shape, ready for the day a real frontend loader
|
||||
* calls register()/unregister() directly instead of pluginLoader.ts's
|
||||
* hand-duplicated stopgap wiring.
|
||||
*/
|
||||
|
||||
export const id = "remote-desktop";
|
||||
export const tabIds = ["rdp", "vnc", "telnet"];
|
||||
|
||||
export async function register() {
|
||||
// No-op: see module comment.
|
||||
}
|
||||
|
||||
export async function unregister() {
|
||||
// No-op: see module comment.
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { needsRdpCredentialPrompt } from "@/features/guacamole/rdp-credential-prompt";
|
||||
import { needsRdpCredentialPrompt } from "./rdp-credential-prompt";
|
||||
|
||||
describe("needsRdpCredentialPrompt", () => {
|
||||
it("prompts recipients when RDP has no saved authentication", () => {
|
||||
File renamed without changes.
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"id": "remote-desktop",
|
||||
"name": "Remote Desktop",
|
||||
"version": "1.0.0",
|
||||
"description": "RDP, VNC and Telnet remote desktop sessions, with clipboard, drive redirection, session recording and session sharing.",
|
||||
"author": { "name": "Termix" },
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/Termix-SSH/Plugin-RemoteDesktop",
|
||||
"category": "Terminal",
|
||||
"icon": "Monitor",
|
||||
"engine": { "termix": ">=2.9.0", "api": "1" },
|
||||
"capabilities": {
|
||||
"backend": true,
|
||||
"frontend": true,
|
||||
"electron": true,
|
||||
"platforms": ["linux", "win32", "darwin"]
|
||||
},
|
||||
"permissions": ["process:transport-owner"],
|
||||
"contributes": {
|
||||
"tabs": [
|
||||
{
|
||||
"id": "rdp",
|
||||
"titleKey": "hosts.tabRdp",
|
||||
"icon": "Monitor",
|
||||
"openFrom": ["host-context-menu", "palette"]
|
||||
},
|
||||
{
|
||||
"id": "vnc",
|
||||
"titleKey": "hosts.tabVnc",
|
||||
"icon": "MousePointerClick",
|
||||
"openFrom": ["host-context-menu", "palette"]
|
||||
},
|
||||
{
|
||||
"id": "telnet",
|
||||
"titleKey": "hosts.tabTelnet",
|
||||
"icon": "Terminal",
|
||||
"openFrom": ["host-context-menu", "palette"]
|
||||
}
|
||||
],
|
||||
"hostCapability": [
|
||||
{ "key": "enableRdp", "labelKey": "hosts.tabRdp", "editorTab": "rdp" },
|
||||
{ "key": "enableVnc", "labelKey": "hosts.tabVnc", "editorTab": "vnc" },
|
||||
{
|
||||
"key": "enableTelnet",
|
||||
"labelKey": "hosts.tabTelnet",
|
||||
"editorTab": "telnet"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sidecars": []
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,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",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user