mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15: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 GuacamoleLite from "guacamole-lite";
|
||||||
import { guacLogger } from "../../utils/logger.js";
|
import { guacLogger } from "../../../src/backend/utils/logger.js";
|
||||||
import {
|
import {
|
||||||
GuacamoleTokenService,
|
GuacamoleTokenService,
|
||||||
type GuacamoleRecordingMetadata,
|
type GuacamoleRecordingMetadata,
|
||||||
@@ -7,8 +7,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
createCurrentSessionRecordingRepository,
|
createCurrentSessionRecordingRepository,
|
||||||
getCurrentSettingValue,
|
getCurrentSettingValue,
|
||||||
} from "../../database/repositories/factory.js";
|
} from "../../../src/backend/database/repositories/factory.js";
|
||||||
import { resolveGuacdOptions } from "../../utils/guacd-config.js";
|
import { resolveGuacdOptions } from "../../../src/backend/utils/guacd-config.js";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ function readGuacdOptions(): { host: string; port: number } {
|
|||||||
return resolveGuacdOptions(dbUrl);
|
return resolveGuacdOptions(dbUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
const GUAC_WS_PORT = 30008;
|
export const GUAC_WS_PORT = 30008;
|
||||||
const DATA_DIR = process.env.DATA_DIR || "./db/data";
|
const DATA_DIR = process.env.DATA_DIR || "./db/data";
|
||||||
const GUACAMOLE_RECORDINGS_DIR =
|
const GUACAMOLE_RECORDINGS_DIR =
|
||||||
process.env.GUACD_RECORDING_BACKEND_PATH ||
|
process.env.GUACD_RECORDING_BACKEND_PATH ||
|
||||||
@@ -250,9 +250,34 @@ function createGuacServer(): GuacamoleLite {
|
|||||||
return server;
|
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> {
|
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 {
|
try {
|
||||||
guacServer.close();
|
guacServer.close();
|
||||||
} catch (err) {
|
} 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 express from "express";
|
||||||
import { GuacamoleTokenService } from "./token-service.js";
|
import { GuacamoleTokenService } from "./token-service.js";
|
||||||
import { withRecordingSettings } from "./recording-settings.js";
|
import { withRecordingSettings } from "./recording-settings.js";
|
||||||
import { withDriveSettings } from "./drive-settings.js";
|
import { withDriveSettings } from "./drive-settings.js";
|
||||||
import { guacLogger } from "../../utils/logger.js";
|
import { guacLogger } from "../../../src/backend/utils/logger.js";
|
||||||
import { AuthManager } from "../../utils/auth-manager.js";
|
import { AuthManager } from "../../../src/backend/utils/auth-manager.js";
|
||||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
import { PermissionManager } from "../../../src/backend/utils/permission-manager.js";
|
||||||
import {
|
import {
|
||||||
resolveRecipientSharedHostAuthentication,
|
resolveRecipientSharedHostAuthentication,
|
||||||
type RecipientSharedHostAuthResolution,
|
type RecipientSharedHostAuthResolution,
|
||||||
} from "../../utils/shared-host-auth-resolver.js";
|
} from "../../../src/backend/utils/shared-host-auth-resolver.js";
|
||||||
import type { AuthOverrideProtocol } from "../../../types/auth-protocols.js";
|
import type { AuthOverrideProtocol } from "../../../src/types/auth-protocols.js";
|
||||||
import net from "net";
|
import net from "net";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
import type { AuthenticatedRequest } from "../../../src/types/index.js";
|
||||||
import {
|
import {
|
||||||
createCurrentHostResolutionRepository,
|
createCurrentHostResolutionRepository,
|
||||||
createCurrentSettingsRepository,
|
createCurrentSettingsRepository,
|
||||||
} from "../../database/repositories/factory.js";
|
} from "../../../src/backend/database/repositories/factory.js";
|
||||||
import { resolveGuacdOptions } from "../../utils/guacd-config.js";
|
import { resolveGuacdOptions } from "../../../src/backend/utils/guacd-config.js";
|
||||||
import { createJumpHostChain } from "../jump-host-chain.js";
|
import { createJumpHostChain } from "../../../src/backend/hosts/jump-host-chain.js";
|
||||||
import { getGuacSessionByConnectId } from "./guacamole-server.js";
|
import { getGuacSessionByConnectId } from "./guacamole-server.js";
|
||||||
import {
|
import {
|
||||||
logAudit,
|
logAudit,
|
||||||
getAuditUsername,
|
getAuditUsername,
|
||||||
getRequestMeta,
|
getRequestMeta,
|
||||||
} from "../../utils/audit-logger.js";
|
} from "../../../src/backend/utils/audit-logger.js";
|
||||||
import { resolveJumpTunnelEndpoint } from "./jump-tunnel-endpoint.js";
|
import { resolveJumpTunnelEndpoint } from "./jump-tunnel-endpoint.js";
|
||||||
import {
|
import {
|
||||||
buildRdpSettings,
|
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 crypto from "crypto";
|
||||||
import { guacLogger } from "../../utils/logger.js";
|
import { guacLogger } from "../../../src/backend/utils/logger.js";
|
||||||
|
|
||||||
export interface GuacamoleConnectionSettings {
|
export interface GuacamoleConnectionSettings {
|
||||||
type?: "rdp" | "vnc" | "telnet";
|
type?: "rdp" | "vnc" | "telnet";
|
||||||
+34
-29
@@ -1,5 +1,5 @@
|
|||||||
import { watchGuacamoleConnectionId } from "./guacamole-session-id";
|
import { watchGuacamoleConnectionId } from "./guacamole-session-id";
|
||||||
import { getErrorMessage } from "../../lib/error-message.js";
|
import { getErrorMessage } from "@/lib/error-message.js";
|
||||||
import React, {
|
import React, {
|
||||||
useState,
|
useState,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
GuacamoleDisplay,
|
GuacamoleDisplay,
|
||||||
type GuacamoleDisplayHandle,
|
type GuacamoleDisplayHandle,
|
||||||
type GuacamoleTouchMode,
|
type GuacamoleTouchMode,
|
||||||
} from "@/features/guacamole/GuacamoleDisplay.tsx";
|
} from "./GuacamoleDisplay.tsx";
|
||||||
import {
|
import {
|
||||||
getGuacamoleTokenFromHost,
|
getGuacamoleTokenFromHost,
|
||||||
getGuacdStatus,
|
getGuacdStatus,
|
||||||
@@ -21,17 +21,17 @@ import {
|
|||||||
logActivity,
|
logActivity,
|
||||||
isElectron,
|
isElectron,
|
||||||
} from "@/main-axios.ts";
|
} from "@/main-axios.ts";
|
||||||
import { readConfiguredDimension } from "@/features/guacamole/guacamole-display-size.ts";
|
import { readConfiguredDimension } from "./guacamole-display-size.ts";
|
||||||
import { getGuacamoleToken, parseGuacamoleConfig } from "@/api/guacamole-api";
|
import { getGuacamoleToken, parseGuacamoleConfig } from "./guacamole-api";
|
||||||
import {
|
import {
|
||||||
resolveConnectionOrigin,
|
resolveConnectionOrigin,
|
||||||
type ConnectionOrigin,
|
type ConnectionOrigin,
|
||||||
} from "@/lib/connection-origin.ts";
|
} from "@/lib/connection-origin.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { GuacamoleToolbar } from "@/features/guacamole/GuacamoleToolbar.tsx";
|
import { GuacamoleToolbar } from "./GuacamoleToolbar.tsx";
|
||||||
import { GuacamoleFileBrowser } from "@/features/guacamole/GuacamoleFileBrowser.tsx";
|
import { GuacamoleFileBrowser } from "./GuacamoleFileBrowser.tsx";
|
||||||
import { describeUploadError } from "@/features/guacamole/guacamole-filesystem.ts";
|
import { describeUploadError } from "./guacamole-filesystem.ts";
|
||||||
import { canUploadToRdpDrive } from "@/features/guacamole/guacamole-file-drop.ts";
|
import { canUploadToRdpDrive } from "./guacamole-file-drop.ts";
|
||||||
import { Button } from "@/components/button.tsx";
|
import { Button } from "@/components/button.tsx";
|
||||||
import { Input } from "@/components/input.tsx";
|
import { Input } from "@/components/input.tsx";
|
||||||
import { PasswordInput } from "@/components/password-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 type { SSHHost } from "@/types";
|
||||||
import { useConnectionDefaults } from "@/contexts/ConnectionDefaultsContext";
|
import { useConnectionDefaults } from "@/contexts/ConnectionDefaultsContext";
|
||||||
import { resolveConnectionDefaults } from "@/lib/connection-defaults";
|
import { resolveConnectionDefaults } from "@/lib/connection-defaults";
|
||||||
import { needsRdpCredentialPrompt } from "@/features/guacamole/rdp-credential-prompt";
|
import { needsRdpCredentialPrompt } from "./rdp-credential-prompt";
|
||||||
|
|
||||||
interface GuacamoleAppProps {
|
interface GuacamoleAppProps {
|
||||||
hostId?: string;
|
hostId?: string;
|
||||||
@@ -139,7 +139,7 @@ const GuacamoleApp = React.forwardRef<GuacamoleAppHandle, GuacamoleAppProps>(
|
|||||||
<div className="relative w-full h-full">
|
<div className="relative w-full h-full">
|
||||||
<ConnectionScreen
|
<ConnectionScreen
|
||||||
status="disconnected"
|
status="disconnected"
|
||||||
message={t("guacamole.hostNotFound")}
|
message={t("remoteDesktop.hostNotFound")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -249,11 +249,13 @@ const GuacamoleAppInner = React.forwardRef<
|
|||||||
const display = displayRef.current;
|
const display = displayRef.current;
|
||||||
if (!display) throw new Error("RDP session is not ready");
|
if (!display) throw new Error("RDP session is not ready");
|
||||||
await display.uploadFile(file);
|
await display.uploadFile(file);
|
||||||
toast.success(t("guacamole.files.uploaded", { name: file.name }));
|
toast.success(
|
||||||
|
t("remoteDesktop.files.uploaded", { name: file.name }),
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(
|
toast.error(
|
||||||
describeUploadError(error, (key) =>
|
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(
|
toast.error(
|
||||||
t(
|
t(
|
||||||
allowUpload
|
allowUpload
|
||||||
? "guacamole.files.driveUnavailable"
|
? "remoteDesktop.files.driveUnavailable"
|
||||||
: "guacamole.files.uploadDisabled",
|
: "remoteDesktop.files.uploadDisabled",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}, [allowUpload, t]);
|
}, [allowUpload, t]);
|
||||||
@@ -329,17 +331,17 @@ const GuacamoleAppInner = React.forwardRef<
|
|||||||
addLog({
|
addLog({
|
||||||
type: "info",
|
type: "info",
|
||||||
stage: "guac_guacd",
|
stage: "guac_guacd",
|
||||||
message: t("guacamole.checkingGuacd"),
|
message: t("remoteDesktop.checkingGuacd"),
|
||||||
});
|
});
|
||||||
const status = await getGuacdStatus(resolvedOrigin);
|
const status = await getGuacdStatus(resolvedOrigin);
|
||||||
if (status.guacd.status !== "connected") {
|
if (status.guacd.status !== "connected") {
|
||||||
throw new Error(t("guacamole.guacdUnavailable"));
|
throw new Error(t("remoteDesktop.guacdUnavailable"));
|
||||||
}
|
}
|
||||||
|
|
||||||
addLog({
|
addLog({
|
||||||
type: "info",
|
type: "info",
|
||||||
stage: "guac_token",
|
stage: "guac_token",
|
||||||
message: t("guacamole.requestingToken", {
|
message: t("remoteDesktop.requestingToken", {
|
||||||
type: resolvedProtocolForConnect.toUpperCase(),
|
type: resolvedProtocolForConnect.toUpperCase(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -408,8 +410,11 @@ const GuacamoleAppInner = React.forwardRef<
|
|||||||
await fetchToken();
|
await fetchToken();
|
||||||
tokenRetryRef.current.markConnected();
|
tokenRetryRef.current.markConnected();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = getErrorMessage(err, t("guacamole.failedToConnect"));
|
const message = getErrorMessage(
|
||||||
setError(message || t("guacamole.failedToConnect"));
|
err,
|
||||||
|
t("remoteDesktop.failedToConnect"),
|
||||||
|
);
|
||||||
|
setError(message || t("remoteDesktop.failedToConnect"));
|
||||||
addLog({ type: "error", stage: "error", message });
|
addLog({ type: "error", stage: "error", message });
|
||||||
tokenRetryRef.current.markFailed();
|
tokenRetryRef.current.markFailed();
|
||||||
}
|
}
|
||||||
@@ -471,10 +476,10 @@ const GuacamoleAppInner = React.forwardRef<
|
|||||||
<DialogContent className="sm:max-w-sm">
|
<DialogContent className="sm:max-w-sm">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-lg font-bold">
|
<DialogTitle className="text-lg font-bold">
|
||||||
{t("guacamole.credentialPromptTitle")}
|
{t("remoteDesktop.credentialPromptTitle")}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-xs text-muted-foreground">
|
<DialogDescription className="text-xs text-muted-foreground">
|
||||||
{t("guacamole.credentialPromptDescription")}
|
{t("remoteDesktop.credentialPromptDescription")}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form
|
<form
|
||||||
@@ -523,7 +528,7 @@ const GuacamoleAppInner = React.forwardRef<
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end gap-2 mt-2">
|
<div className="flex items-center justify-end gap-2 mt-2">
|
||||||
<Button type="submit" variant="outline">
|
<Button type="submit" variant="outline">
|
||||||
{t("guacamole.connect")}
|
{t("remoteDesktop.connect")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -537,7 +542,7 @@ const GuacamoleAppInner = React.forwardRef<
|
|||||||
<div className="relative w-full h-full">
|
<div className="relative w-full h-full">
|
||||||
<ConnectionScreen
|
<ConnectionScreen
|
||||||
status={error ? tokenRetry.status : "connecting"}
|
status={error ? tokenRetry.status : "connecting"}
|
||||||
message={t("guacamole.connecting", {
|
message={t("remoteDesktop.connecting", {
|
||||||
type: (
|
type: (
|
||||||
protocol ||
|
protocol ||
|
||||||
hostConfig.connectionType ||
|
hostConfig.connectionType ||
|
||||||
@@ -548,7 +553,7 @@ const GuacamoleAppInner = React.forwardRef<
|
|||||||
maxAttempts={tokenRetry.maxAttempts}
|
maxAttempts={tokenRetry.maxAttempts}
|
||||||
nextRetryInMs={tokenRetry.nextRetryInMs}
|
nextRetryInMs={tokenRetry.nextRetryInMs}
|
||||||
onManualRetry={handleReconnect}
|
onManualRetry={handleReconnect}
|
||||||
retryLabel={t("guacamole.retry")}
|
retryLabel={t("remoteDesktop.retry")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -564,11 +569,11 @@ const GuacamoleAppInner = React.forwardRef<
|
|||||||
{(!isDisplayReady || connectionError) && (
|
{(!isDisplayReady || connectionError) && (
|
||||||
<ConnectionScreen
|
<ConnectionScreen
|
||||||
status={connectionError ? "disconnected" : "connecting"}
|
status={connectionError ? "disconnected" : "connecting"}
|
||||||
message={t("guacamole.connecting", {
|
message={t("remoteDesktop.connecting", {
|
||||||
type: resolvedProtocol.toUpperCase(),
|
type: resolvedProtocol.toUpperCase(),
|
||||||
})}
|
})}
|
||||||
onManualRetry={handleReconnect}
|
onManualRetry={handleReconnect}
|
||||||
retryLabel={t("guacamole.reconnect")}
|
retryLabel={t("remoteDesktop.reconnect")}
|
||||||
className="z-50"
|
className="z-50"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -603,21 +608,21 @@ const GuacamoleAppInner = React.forwardRef<
|
|||||||
addLog({
|
addLog({
|
||||||
type: "info",
|
type: "info",
|
||||||
stage,
|
stage,
|
||||||
message: t("guacamole.openingSession", { type }),
|
message: t("remoteDesktop.openingSession", { type }),
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case "guac_handshake":
|
case "guac_handshake":
|
||||||
addLog({
|
addLog({
|
||||||
type: "info",
|
type: "info",
|
||||||
stage,
|
stage,
|
||||||
message: t("guacamole.negotiating", { type }),
|
message: t("remoteDesktop.negotiating", { type }),
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case "guac_ready":
|
case "guac_ready":
|
||||||
addLog({
|
addLog({
|
||||||
type: "success",
|
type: "success",
|
||||||
stage,
|
stage,
|
||||||
message: t("guacamole.sessionReady", { type }),
|
message: t("remoteDesktop.sessionReady", { type }),
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
default:
|
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 type React from "react";
|
||||||
import {
|
import {
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -613,7 +613,7 @@ export const GuacamoleDisplay = forwardRef<
|
|||||||
setHasError(true);
|
setHasError(true);
|
||||||
hasKeyboardFocusRef.current = false;
|
hasKeyboardFocusRef.current = false;
|
||||||
refreshKeyboardHandlers();
|
refreshKeyboardHandlers();
|
||||||
onError?.(t("guacamole.connectionError"));
|
onError?.(t("remoteDesktop.connectionError"));
|
||||||
onDisconnect?.();
|
onDisconnect?.();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -621,7 +621,7 @@ export const GuacamoleDisplay = forwardRef<
|
|||||||
|
|
||||||
client.onerror = (error: Guacamole.Status) => {
|
client.onerror = (error: Guacamole.Status) => {
|
||||||
if (!isMountedRef.current || clientRef.current !== client) return;
|
if (!isMountedRef.current || clientRef.current !== client) return;
|
||||||
const errorMessage = error.message || t("guacamole.connectionError");
|
const errorMessage = error.message || t("remoteDesktop.connectionError");
|
||||||
setIsReady(false);
|
setIsReady(false);
|
||||||
setHasError(true);
|
setHasError(true);
|
||||||
isConnectingRef.current = false;
|
isConnectingRef.current = false;
|
||||||
@@ -696,7 +696,7 @@ export const GuacamoleDisplay = forwardRef<
|
|||||||
if (!isMountedRef.current) return;
|
if (!isMountedRef.current) return;
|
||||||
setIsReady(false);
|
setIsReady(false);
|
||||||
setHasError(true);
|
setHasError(true);
|
||||||
onError?.(getErrorMessage(error, t("guacamole.connectionError")));
|
onError?.(getErrorMessage(error, t("remoteDesktop.connectionError")));
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
getWebSocketConnection,
|
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="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">
|
<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" />
|
<Upload className="size-4" />
|
||||||
{t("guacamole.files.dropToUpload")}
|
{t("remoteDesktop.files.dropToUpload")}
|
||||||
</div>
|
</div>
|
||||||
</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 { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import Guacamole from "guacamole-common-js";
|
import Guacamole from "guacamole-common-js";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -56,7 +56,7 @@ export function GuacamoleFileBrowser({
|
|||||||
setEntries(await listDirectory(filesystem, target));
|
setEntries(await listDirectory(filesystem, target));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setEntries([]);
|
setEntries([]);
|
||||||
setError(getErrorMessage(err, t("guacamole.files.listFailed")));
|
setError(getErrorMessage(err, t("remoteDesktop.files.listFailed")));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -74,11 +74,11 @@ export function GuacamoleFileBrowser({
|
|||||||
setBusyName(file.name);
|
setBusyName(file.name);
|
||||||
try {
|
try {
|
||||||
await uploadFile(filesystem, path, file);
|
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) {
|
} catch (err) {
|
||||||
toast.error(
|
toast.error(
|
||||||
describeUploadError(err, (key) =>
|
describeUploadError(err, (key) =>
|
||||||
t(`guacamole.files.${key}`, { name: file.name }),
|
t(`remoteDesktop.files.${key}`, { name: file.name }),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -96,7 +96,7 @@ export function GuacamoleFileBrowser({
|
|||||||
if (pendingUploads.length === 0) return;
|
if (pendingUploads.length === 0) return;
|
||||||
onPendingUploadsHandled();
|
onPendingUploadsHandled();
|
||||||
if (!allowUpload) {
|
if (!allowUpload) {
|
||||||
toast.error(t("guacamole.files.uploadDisabled"));
|
toast.error(t("remoteDesktop.files.uploadDisabled"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void upload(pendingUploads);
|
void upload(pendingUploads);
|
||||||
@@ -112,7 +112,7 @@ export function GuacamoleFileBrowser({
|
|||||||
toast.error(
|
toast.error(
|
||||||
err instanceof Error
|
err instanceof Error
|
||||||
? err.message
|
? err.message
|
||||||
: t("guacamole.files.downloadFailed", { name: entry.name }),
|
: t("remoteDesktop.files.downloadFailed", { name: entry.name }),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setBusyName(null);
|
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="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">
|
<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">
|
<span className="flex-1 truncate text-xs font-semibold">
|
||||||
{t("guacamole.files.title")}
|
{t("remoteDesktop.files.title")}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -145,7 +145,7 @@ export function GuacamoleFileBrowser({
|
|||||||
className="size-6"
|
className="size-6"
|
||||||
disabled={path === "/"}
|
disabled={path === "/"}
|
||||||
onClick={() => setPath(parentPath(path))}
|
onClick={() => setPath(parentPath(path))}
|
||||||
aria-label={t("guacamole.files.parent")}
|
aria-label={t("remoteDesktop.files.parent")}
|
||||||
>
|
>
|
||||||
<ArrowUp className="size-3.5" />
|
<ArrowUp className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -157,7 +157,7 @@ export function GuacamoleFileBrowser({
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="size-6"
|
className="size-6"
|
||||||
onClick={() => void refresh(path)}
|
onClick={() => void refresh(path)}
|
||||||
aria-label={t("guacamole.files.refresh")}
|
aria-label={t("remoteDesktop.files.refresh")}
|
||||||
>
|
>
|
||||||
<RefreshCw className="size-3.5" />
|
<RefreshCw className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -174,7 +174,7 @@ export function GuacamoleFileBrowser({
|
|||||||
)}
|
)}
|
||||||
{!loading && !error && entries.length === 0 && (
|
{!loading && !error && entries.length === 0 && (
|
||||||
<p className="px-2 py-3 text-[11px] text-muted-foreground">
|
<p className="px-2 py-3 text-[11px] text-muted-foreground">
|
||||||
{t("guacamole.files.empty")}
|
{t("remoteDesktop.files.empty")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{!loading &&
|
{!loading &&
|
||||||
@@ -204,7 +204,7 @@ export function GuacamoleFileBrowser({
|
|||||||
className="size-5 shrink-0"
|
className="size-5 shrink-0"
|
||||||
disabled={busyName === entry.name}
|
disabled={busyName === entry.name}
|
||||||
onClick={() => void download(entry)}
|
onClick={() => void download(entry)}
|
||||||
aria-label={t("guacamole.files.download")}
|
aria-label={t("remoteDesktop.files.download")}
|
||||||
>
|
>
|
||||||
<Download className="size-3" />
|
<Download className="size-3" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -235,8 +235,8 @@ export function GuacamoleFileBrowser({
|
|||||||
>
|
>
|
||||||
<Upload className="mr-1 size-3" />
|
<Upload className="mr-1 size-3" />
|
||||||
{busyName !== null
|
{busyName !== null
|
||||||
? t("guacamole.files.uploading", { name: busyName })
|
? t("remoteDesktop.files.uploading", { name: busyName })
|
||||||
: t("guacamole.files.upload")}
|
: t("remoteDesktop.files.upload")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
+6
-6
@@ -1,8 +1,8 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { fireEvent, render } from "@testing-library/react";
|
import { fireEvent, render } from "@testing-library/react";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { GuacamoleToolbar } from "../../../features/guacamole/GuacamoleToolbar.js";
|
import { GuacamoleToolbar } from "./GuacamoleToolbar.js";
|
||||||
import type { GuacamoleDisplayHandle } from "../../../features/guacamole/GuacamoleDisplay.js";
|
import type { GuacamoleDisplayHandle } from "./GuacamoleDisplay.js";
|
||||||
|
|
||||||
vi.mock("react-i18next", () => ({
|
vi.mock("react-i18next", () => ({
|
||||||
useTranslation: () => ({ t: (key: string) => key }),
|
useTranslation: () => ({ t: (key: string) => key }),
|
||||||
@@ -49,7 +49,7 @@ describe("GuacamoleToolbar Windows key", () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
sendKey.mockClear();
|
sendKey.mockClear();
|
||||||
fireEvent.click(getByText("guacamole.toolbar.win"));
|
fireEvent.click(getByText("remoteDesktop.toolbar.win"));
|
||||||
expect(sendKey).toHaveBeenCalledWith(0xffeb, true);
|
expect(sendKey).toHaveBeenCalledWith(0xffeb, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -75,8 +75,8 @@ describe("GuacamoleToolbar Windows key", () => {
|
|||||||
<GuacamoleToolbar displayRef={displayRef} protocol="vnc" zoom={1.25} />,
|
<GuacamoleToolbar displayRef={displayRef} protocol="vnc" zoom={1.25} />,
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.click(getByLabelText("guacamole.toolbar.zoomOut"));
|
fireEvent.click(getByLabelText("remoteDesktop.toolbar.zoomOut"));
|
||||||
fireEvent.click(getByLabelText("guacamole.toolbar.zoomIn"));
|
fireEvent.click(getByLabelText("remoteDesktop.toolbar.zoomIn"));
|
||||||
fireEvent.click(getByText("125%"));
|
fireEvent.click(getByText("125%"));
|
||||||
expect(zoomOut).toHaveBeenCalledOnce();
|
expect(zoomOut).toHaveBeenCalledOnce();
|
||||||
expect(zoomIn).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();
|
expect(onHide).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
+38
-30
@@ -30,14 +30,14 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
GuacamoleDisplayHandle,
|
GuacamoleDisplayHandle,
|
||||||
GuacamoleTouchMode,
|
GuacamoleTouchMode,
|
||||||
} from "@/features/guacamole/GuacamoleDisplay.tsx";
|
} from "./GuacamoleDisplay.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
detectRuntimeMetaKeyFamily,
|
detectRuntimeMetaKeyFamily,
|
||||||
metaKeyLabels,
|
metaKeyLabels,
|
||||||
type MetaKeyFamily,
|
type MetaKeyFamily,
|
||||||
} from "@/features/guacamole/guacamole-meta-key.ts";
|
} from "./guacamole-meta-key.ts";
|
||||||
|
|
||||||
interface GuacamoleToolbarProps {
|
interface GuacamoleToolbarProps {
|
||||||
displayRef: React.RefObject<GuacamoleDisplayHandle>;
|
displayRef: React.RefObject<GuacamoleDisplayHandle>;
|
||||||
@@ -302,7 +302,7 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom" sideOffset={6}>
|
<TooltipContent side="bottom" sideOffset={6}>
|
||||||
{t("guacamole.toolbar.expand")}
|
{t("remoteDesktop.toolbar.expand")}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
@@ -319,7 +319,7 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom" sideOffset={6}>
|
<TooltipContent side="bottom" sideOffset={6}>
|
||||||
{t("guacamole.toolbar.dragHandle")}
|
{t("remoteDesktop.toolbar.dragHandle")}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
@@ -349,8 +349,8 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom" sideOffset={6}>
|
<TooltipContent side="bottom" sideOffset={6}>
|
||||||
{touchMode === "touchscreen"
|
{touchMode === "touchscreen"
|
||||||
? t("guacamole.toolbar.switchToTrackpad")
|
? t("remoteDesktop.toolbar.switchToTrackpad")
|
||||||
: t("guacamole.toolbar.switchToTouch")}
|
: t("remoteDesktop.toolbar.switchToTouch")}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</>
|
</>
|
||||||
@@ -361,7 +361,7 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
|
|||||||
<>
|
<>
|
||||||
<div className={SEP} />
|
<div className={SEP} />
|
||||||
<TipIconBtn
|
<TipIconBtn
|
||||||
tooltip={t("guacamole.files.title")}
|
tooltip={t("remoteDesktop.files.title")}
|
||||||
onClick={onToggleFileBrowser}
|
onClick={onToggleFileBrowser}
|
||||||
className={cn(fileBrowserOpen && "bg-muted text-foreground")}
|
className={cn(fileBrowserOpen && "bg-muted text-foreground")}
|
||||||
>
|
>
|
||||||
@@ -374,26 +374,26 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
|
|||||||
<>
|
<>
|
||||||
<div className={SEP} />
|
<div className={SEP} />
|
||||||
<TipIconBtn
|
<TipIconBtn
|
||||||
tooltip={t("guacamole.toolbar.zoomOut")}
|
tooltip={t("remoteDesktop.toolbar.zoomOut")}
|
||||||
onClick={() => displayRef.current?.zoomOut()}
|
onClick={() => displayRef.current?.zoomOut()}
|
||||||
>
|
>
|
||||||
<ZoomOut className="size-3.5" />
|
<ZoomOut className="size-3.5" />
|
||||||
</TipIconBtn>
|
</TipIconBtn>
|
||||||
<TipBtn
|
<TipBtn
|
||||||
tooltip={t("guacamole.toolbar.resetZoom")}
|
tooltip={t("remoteDesktop.toolbar.resetZoom")}
|
||||||
onClick={() => displayRef.current?.resetZoom()}
|
onClick={() => displayRef.current?.resetZoom()}
|
||||||
className="min-w-12 tabular-nums"
|
className="min-w-12 tabular-nums"
|
||||||
>
|
>
|
||||||
{Math.round(zoom * 100)}%
|
{Math.round(zoom * 100)}%
|
||||||
</TipBtn>
|
</TipBtn>
|
||||||
<TipIconBtn
|
<TipIconBtn
|
||||||
tooltip={t("guacamole.toolbar.zoomIn")}
|
tooltip={t("remoteDesktop.toolbar.zoomIn")}
|
||||||
onClick={() => displayRef.current?.zoomIn()}
|
onClick={() => displayRef.current?.zoomIn()}
|
||||||
>
|
>
|
||||||
<ZoomIn className="size-3.5" />
|
<ZoomIn className="size-3.5" />
|
||||||
</TipIconBtn>
|
</TipIconBtn>
|
||||||
<TipIconBtn
|
<TipIconBtn
|
||||||
tooltip={t("guacamole.toolbar.fitToScreen")}
|
tooltip={t("remoteDesktop.toolbar.fitToScreen")}
|
||||||
onClick={() => displayRef.current?.resetZoom()}
|
onClick={() => displayRef.current?.resetZoom()}
|
||||||
>
|
>
|
||||||
<Scan className="size-3.5" />
|
<Scan className="size-3.5" />
|
||||||
@@ -406,7 +406,7 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
|
|||||||
<>
|
<>
|
||||||
<div className={SEP} />
|
<div className={SEP} />
|
||||||
<TipBtn
|
<TipBtn
|
||||||
tooltip={t("guacamole.toolbar.ctrlAltDel")}
|
tooltip={t("remoteDesktop.toolbar.ctrlAltDel")}
|
||||||
onClick={() => sendCombo(0xffe3, 0xffe9, 0xffff)}
|
onClick={() => sendCombo(0xffe3, 0xffe9, 0xffff)}
|
||||||
>
|
>
|
||||||
CAD
|
CAD
|
||||||
@@ -435,13 +435,17 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
|
|||||||
[
|
[
|
||||||
"ctrl",
|
"ctrl",
|
||||||
MODIFIER_KEYSYMS.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",
|
"shift",
|
||||||
MODIFIER_KEYSYMS.shift,
|
MODIFIER_KEYSYMS.shift,
|
||||||
t("guacamole.toolbar.shift"),
|
t("remoteDesktop.toolbar.shift"),
|
||||||
],
|
],
|
||||||
["win", MODIFIER_KEYSYMS.win, t(meta.stickyKey)],
|
["win", MODIFIER_KEYSYMS.win, t(meta.stickyKey)],
|
||||||
] as [string, number, string][]
|
] as [string, number, string][]
|
||||||
@@ -462,8 +466,12 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom" sideOffset={6}>
|
<TooltipContent side="bottom" sideOffset={6}>
|
||||||
{stickyKeys[ks]
|
{stickyKeys[ks]
|
||||||
? t("guacamole.toolbar.stickyActive", { key: label })
|
? t("remoteDesktop.toolbar.stickyActive", {
|
||||||
: t("guacamole.toolbar.stickyInactive", { key: label })}
|
key: label,
|
||||||
|
})
|
||||||
|
: t("remoteDesktop.toolbar.stickyInactive", {
|
||||||
|
key: label,
|
||||||
|
})}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
))}
|
))}
|
||||||
@@ -473,7 +481,7 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
|
|||||||
{/* Function key toggle */}
|
{/* Function key toggle */}
|
||||||
<div className={SEP} />
|
<div className={SEP} />
|
||||||
<TipBtn
|
<TipBtn
|
||||||
tooltip={t("guacamole.toolbar.fnToggle")}
|
tooltip={t("remoteDesktop.toolbar.fnToggle")}
|
||||||
onClick={() => setShowFKeys((v) => !v)}
|
onClick={() => setShowFKeys((v) => !v)}
|
||||||
className={cn(
|
className={cn(
|
||||||
showFKeys &&
|
showFKeys &&
|
||||||
@@ -498,37 +506,37 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
|
|||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<div className={SEP} />
|
<div className={SEP} />
|
||||||
<TipBtn
|
<TipBtn
|
||||||
tooltip={t("guacamole.toolbar.esc")}
|
tooltip={t("remoteDesktop.toolbar.esc")}
|
||||||
onClick={() => sendCombo(0xff1b)}
|
onClick={() => sendCombo(0xff1b)}
|
||||||
>
|
>
|
||||||
Esc
|
Esc
|
||||||
</TipBtn>
|
</TipBtn>
|
||||||
<TipBtn
|
<TipBtn
|
||||||
tooltip={t("guacamole.toolbar.tab")}
|
tooltip={t("remoteDesktop.toolbar.tab")}
|
||||||
onClick={() => sendCombo(0xff09)}
|
onClick={() => sendCombo(0xff09)}
|
||||||
>
|
>
|
||||||
Tab
|
Tab
|
||||||
</TipBtn>
|
</TipBtn>
|
||||||
<TipBtn
|
<TipBtn
|
||||||
tooltip={t("guacamole.toolbar.home")}
|
tooltip={t("remoteDesktop.toolbar.home")}
|
||||||
onClick={() => sendCombo(0xff50)}
|
onClick={() => sendCombo(0xff50)}
|
||||||
>
|
>
|
||||||
Home
|
Home
|
||||||
</TipBtn>
|
</TipBtn>
|
||||||
<TipBtn
|
<TipBtn
|
||||||
tooltip={t("guacamole.toolbar.end")}
|
tooltip={t("remoteDesktop.toolbar.end")}
|
||||||
onClick={() => sendCombo(0xff57)}
|
onClick={() => sendCombo(0xff57)}
|
||||||
>
|
>
|
||||||
End
|
End
|
||||||
</TipBtn>
|
</TipBtn>
|
||||||
<TipBtn
|
<TipBtn
|
||||||
tooltip={t("guacamole.toolbar.pageUp")}
|
tooltip={t("remoteDesktop.toolbar.pageUp")}
|
||||||
onClick={() => sendCombo(0xff55)}
|
onClick={() => sendCombo(0xff55)}
|
||||||
>
|
>
|
||||||
PgUp
|
PgUp
|
||||||
</TipBtn>
|
</TipBtn>
|
||||||
<TipBtn
|
<TipBtn
|
||||||
tooltip={t("guacamole.toolbar.pageDown")}
|
tooltip={t("remoteDesktop.toolbar.pageDown")}
|
||||||
onClick={() => sendCombo(0xff56)}
|
onClick={() => sendCombo(0xff56)}
|
||||||
>
|
>
|
||||||
PgDn
|
PgDn
|
||||||
@@ -538,7 +546,7 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
|
|||||||
<div className="flex flex-col ml-0.5">
|
<div className="flex flex-col ml-0.5">
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<TipIconBtn
|
<TipIconBtn
|
||||||
tooltip={t("guacamole.toolbar.arrowUp")}
|
tooltip={t("remoteDesktop.toolbar.arrowUp")}
|
||||||
onClick={() => sendCombo(0xff52)}
|
onClick={() => sendCombo(0xff52)}
|
||||||
>
|
>
|
||||||
<ChevronUp className="size-3" />
|
<ChevronUp className="size-3" />
|
||||||
@@ -546,19 +554,19 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<TipIconBtn
|
<TipIconBtn
|
||||||
tooltip={t("guacamole.toolbar.arrowLeft")}
|
tooltip={t("remoteDesktop.toolbar.arrowLeft")}
|
||||||
onClick={() => sendCombo(0xff51)}
|
onClick={() => sendCombo(0xff51)}
|
||||||
>
|
>
|
||||||
<ChevronLeft className="size-3" />
|
<ChevronLeft className="size-3" />
|
||||||
</TipIconBtn>
|
</TipIconBtn>
|
||||||
<TipIconBtn
|
<TipIconBtn
|
||||||
tooltip={t("guacamole.toolbar.arrowDown")}
|
tooltip={t("remoteDesktop.toolbar.arrowDown")}
|
||||||
onClick={() => sendCombo(0xff54)}
|
onClick={() => sendCombo(0xff54)}
|
||||||
>
|
>
|
||||||
<ChevronDown className="size-3" />
|
<ChevronDown className="size-3" />
|
||||||
</TipIconBtn>
|
</TipIconBtn>
|
||||||
<TipIconBtn
|
<TipIconBtn
|
||||||
tooltip={t("guacamole.toolbar.arrowRight")}
|
tooltip={t("remoteDesktop.toolbar.arrowRight")}
|
||||||
onClick={() => sendCombo(0xff53)}
|
onClick={() => sendCombo(0xff53)}
|
||||||
>
|
>
|
||||||
<ChevronRight className="size-3" />
|
<ChevronRight className="size-3" />
|
||||||
@@ -579,12 +587,12 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom" sideOffset={6}>
|
<TooltipContent side="bottom" sideOffset={6}>
|
||||||
{t("guacamole.toolbar.collapse")}
|
{t("remoteDesktop.toolbar.collapse")}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{onHide && (
|
{onHide && (
|
||||||
<TipIconBtn
|
<TipIconBtn
|
||||||
tooltip={t("guacamole.toolbar.hide")}
|
tooltip={t("remoteDesktop.toolbar.hide")}
|
||||||
onClick={onHide}
|
onClick={onHide}
|
||||||
>
|
>
|
||||||
<X className="size-3.5" />
|
<X className="size-3.5" />
|
||||||
+1
-1
@@ -25,7 +25,7 @@ import {
|
|||||||
getGuacdStatus,
|
getGuacdStatus,
|
||||||
getGuacamoleConnectionId,
|
getGuacamoleConnectionId,
|
||||||
getGuacamoleTokenFromHost,
|
getGuacamoleTokenFromHost,
|
||||||
} from "../../api/guacamole-api";
|
} from "./guacamole-api";
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
authApiMock.get.mockClear();
|
authApiMock.get.mockClear();
|
||||||
File renamed without changes.
+1
-1
@@ -3,7 +3,7 @@ import {
|
|||||||
isPasteShortcut,
|
isPasteShortcut,
|
||||||
pasteTextToRemote,
|
pasteTextToRemote,
|
||||||
type GuacamoleClipboardClient,
|
type GuacamoleClipboardClient,
|
||||||
} from "../../../features/guacamole/guacamole-clipboard.js";
|
} from "./guacamole-clipboard.js";
|
||||||
|
|
||||||
describe("Guacamole clipboard paste", () => {
|
describe("Guacamole clipboard paste", () => {
|
||||||
it("recognizes Ctrl+V and Command+V without intercepting Alt+V", () => {
|
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 {
|
import {
|
||||||
getGuacamoleDisplaySize,
|
getGuacamoleDisplaySize,
|
||||||
readConfiguredDimension,
|
readConfiguredDimension,
|
||||||
} from "../../../features/guacamole/guacamole-display-size";
|
} from "./guacamole-display-size";
|
||||||
|
|
||||||
describe("getGuacamoleDisplaySize", () => {
|
describe("getGuacamoleDisplaySize", () => {
|
||||||
it("requests native pixels and matching DPI for HiDPI RDP", () => {
|
it("requests native pixels and matching DPI for HiDPI RDP", () => {
|
||||||
File renamed without changes.
+1
-1
@@ -3,7 +3,7 @@ import {
|
|||||||
canUploadToRdpDrive,
|
canUploadToRdpDrive,
|
||||||
getFileDropDisposition,
|
getFileDropDisposition,
|
||||||
hasDraggedFiles,
|
hasDraggedFiles,
|
||||||
} from "@/features/guacamole/guacamole-file-drop.ts";
|
} from "./guacamole-file-drop.ts";
|
||||||
|
|
||||||
describe("Guacamole file drop", () => {
|
describe("Guacamole file drop", () => {
|
||||||
it("recognizes external file drags", () => {
|
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 { describe, expect, it } from "vitest";
|
||||||
import {
|
import { detectMetaKeyFamily, metaKeyLabels } from "./guacamole-meta-key.js";
|
||||||
detectMetaKeyFamily,
|
|
||||||
metaKeyLabels,
|
|
||||||
} from "../../../features/guacamole/guacamole-meta-key.js";
|
|
||||||
|
|
||||||
describe("guacamole toolbar meta key labels", () => {
|
describe("guacamole toolbar meta key labels", () => {
|
||||||
it("keeps Windows labels for RDP regardless of the local device", () => {
|
it("keeps Windows labels for RDP regardless of the local device", () => {
|
||||||
+9
-9
@@ -56,9 +56,9 @@ export function metaKeyLabels(family: MetaKeyFamily): MetaKeyLabels {
|
|||||||
family,
|
family,
|
||||||
short: "Cmd",
|
short: "Cmd",
|
||||||
lock: "Cmd+L",
|
lock: "Cmd+L",
|
||||||
stickyKey: "guacamole.toolbar.cmd",
|
stickyKey: "remoteDesktop.toolbar.cmd",
|
||||||
keyTooltip: "guacamole.toolbar.cmdKey",
|
keyTooltip: "remoteDesktop.toolbar.cmdKey",
|
||||||
lockTooltip: "guacamole.toolbar.cmdL",
|
lockTooltip: "remoteDesktop.toolbar.cmdL",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (family === "super") {
|
if (family === "super") {
|
||||||
@@ -66,17 +66,17 @@ export function metaKeyLabels(family: MetaKeyFamily): MetaKeyLabels {
|
|||||||
family,
|
family,
|
||||||
short: "Super",
|
short: "Super",
|
||||||
lock: "Super+L",
|
lock: "Super+L",
|
||||||
stickyKey: "guacamole.toolbar.super",
|
stickyKey: "remoteDesktop.toolbar.super",
|
||||||
keyTooltip: "guacamole.toolbar.superKey",
|
keyTooltip: "remoteDesktop.toolbar.superKey",
|
||||||
lockTooltip: "guacamole.toolbar.superL",
|
lockTooltip: "remoteDesktop.toolbar.superL",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
family,
|
family,
|
||||||
short: "Win",
|
short: "Win",
|
||||||
lock: "Win+L",
|
lock: "Win+L",
|
||||||
stickyKey: "guacamole.toolbar.win",
|
stickyKey: "remoteDesktop.toolbar.win",
|
||||||
keyTooltip: "guacamole.toolbar.winKey",
|
keyTooltip: "remoteDesktop.toolbar.winKey",
|
||||||
lockTooltip: "guacamole.toolbar.winL",
|
lockTooltip: "remoteDesktop.toolbar.winL",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { bindPointerInput } from "../../../features/guacamole/guacamole-pointer.js";
|
import { bindPointerInput } from "./guacamole-pointer.js";
|
||||||
|
|
||||||
function listenedEvents(): {
|
function listenedEvents(): {
|
||||||
element: HTMLElement;
|
element: HTMLElement;
|
||||||
File renamed without changes.
+1
-1
@@ -6,7 +6,7 @@ import {
|
|||||||
MAX_SCROLL_CLICKS_PER_FLUSH,
|
MAX_SCROLL_CLICKS_PER_FLUSH,
|
||||||
SCROLL_FLUSH_INTERVAL_MS,
|
SCROLL_FLUSH_INTERVAL_MS,
|
||||||
takeScrollClicks,
|
takeScrollClicks,
|
||||||
} from "../../../features/guacamole/guacamole-scroll.js";
|
} from "./guacamole-scroll.js";
|
||||||
|
|
||||||
describe("guacamole scroll coalescing", () => {
|
describe("guacamole scroll coalescing", () => {
|
||||||
it("caps a click flood so a laggy tunnel cannot queue a backlog", () => {
|
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";
|
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||||
const fetchId = vi.hoisted(() => vi.fn());
|
const fetchId = vi.hoisted(() => vi.fn());
|
||||||
vi.mock("@/api/guacamole-api", () => ({ getGuacamoleConnectionId: fetchId }));
|
vi.mock("./guacamole-api", () => ({ getGuacamoleConnectionId: fetchId }));
|
||||||
import { watchGuacamoleConnectionId } from "../../../features/guacamole/guacamole-session-id";
|
import { watchGuacamoleConnectionId } from "./guacamole-session-id";
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
fetchId.mockReset();
|
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";
|
import type { ConnectionOrigin } from "@/lib/connection-origin";
|
||||||
|
|
||||||
export function watchGuacamoleConnectionId(
|
export function watchGuacamoleConnectionId(
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { buildGuacamoleWebSocketBaseUrl } from "../../../features/guacamole/guacamole-websocket-url.js";
|
import { buildGuacamoleWebSocketBaseUrl } from "./guacamole-websocket-url.js";
|
||||||
|
|
||||||
const httpsLocation = {
|
const httpsLocation = {
|
||||||
protocol: "https:",
|
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 { describe, expect, it } from "vitest";
|
||||||
import { needsRdpCredentialPrompt } from "@/features/guacamole/rdp-credential-prompt";
|
import { needsRdpCredentialPrompt } from "./rdp-credential-prompt";
|
||||||
|
|
||||||
describe("needsRdpCredentialPrompt", () => {
|
describe("needsRdpCredentialPrompt", () => {
|
||||||
it("prompts recipients when RDP has no saved authentication", () => {
|
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