diff --git a/plugins/remote-desktop/README.md b/plugins/remote-desktop/README.md new file mode 100644 index 000000000..09d55f7a1 --- /dev/null +++ b/plugins/remote-desktop/README.md @@ -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. diff --git a/src/backend/hosts/guacamole/drive-settings.ts b/plugins/remote-desktop/backend/drive-settings.ts similarity index 100% rename from src/backend/hosts/guacamole/drive-settings.ts rename to plugins/remote-desktop/backend/drive-settings.ts diff --git a/src/backend/hosts/guacamole/guacamole-server.ts b/plugins/remote-desktop/backend/guacamole-server.ts similarity index 88% rename from src/backend/hosts/guacamole/guacamole-server.ts rename to plugins/remote-desktop/backend/guacamole-server.ts index f52ee7f0f..22f8de8ac 100644 --- a/src/backend/hosts/guacamole/guacamole-server.ts +++ b/plugins/remote-desktop/backend/guacamole-server.ts @@ -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 { + guacServer = createGuacServer(); +} + +/** + * Stops the guacamole-lite WebSocket server. Called from this plugin's + * deactivate(). + */ +export async function stopGuacamoleService(): Promise { + 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 { + // 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) { diff --git a/plugins/remote-desktop/backend/index.mjs b/plugins/remote-desktop/backend/index.mjs new file mode 100644 index 000000000..22c42d061 --- /dev/null +++ b/plugins/remote-desktop/backend/index.mjs @@ -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; + } +} diff --git a/src/backend/hosts/guacamole/jump-tunnel-endpoint.ts b/plugins/remote-desktop/backend/jump-tunnel-endpoint.ts similarity index 100% rename from src/backend/hosts/guacamole/jump-tunnel-endpoint.ts rename to plugins/remote-desktop/backend/jump-tunnel-endpoint.ts diff --git a/src/backend/hosts/guacamole/macos-vnc-proxy.ts b/plugins/remote-desktop/backend/macos-vnc-proxy.ts similarity index 100% rename from src/backend/hosts/guacamole/macos-vnc-proxy.ts rename to plugins/remote-desktop/backend/macos-vnc-proxy.ts diff --git a/src/backend/hosts/guacamole/rdp-settings.ts b/plugins/remote-desktop/backend/rdp-settings.ts similarity index 100% rename from src/backend/hosts/guacamole/rdp-settings.ts rename to plugins/remote-desktop/backend/rdp-settings.ts diff --git a/src/backend/hosts/guacamole/recording-settings.ts b/plugins/remote-desktop/backend/recording-settings.ts similarity index 100% rename from src/backend/hosts/guacamole/recording-settings.ts rename to plugins/remote-desktop/backend/recording-settings.ts diff --git a/src/backend/hosts/guacamole/routes.ts b/plugins/remote-desktop/backend/routes.ts similarity index 97% rename from src/backend/hosts/guacamole/routes.ts rename to plugins/remote-desktop/backend/routes.ts index 39a27e3fa..515735295 100644 --- a/src/backend/hosts/guacamole/routes.ts +++ b/plugins/remote-desktop/backend/routes.ts @@ -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 }; diff --git a/src/backend/hosts/guacamole/token-service.ts b/plugins/remote-desktop/backend/token-service.ts similarity index 98% rename from src/backend/hosts/guacamole/token-service.ts rename to plugins/remote-desktop/backend/token-service.ts index 8bd34c4bd..8ad4a8d2d 100644 --- a/src/backend/hosts/guacamole/token-service.ts +++ b/plugins/remote-desktop/backend/token-service.ts @@ -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"; diff --git a/src/ui/features/guacamole/GuacamoleApp.tsx b/plugins/remote-desktop/frontend/GuacamoleApp.tsx similarity index 91% rename from src/ui/features/guacamole/GuacamoleApp.tsx rename to plugins/remote-desktop/frontend/GuacamoleApp.tsx index d920b5c4b..744b7eb60 100644 --- a/src/ui/features/guacamole/GuacamoleApp.tsx +++ b/plugins/remote-desktop/frontend/GuacamoleApp.tsx @@ -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(
); @@ -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< - {t("guacamole.credentialPromptTitle")} + {t("remoteDesktop.credentialPromptTitle")} - {t("guacamole.credentialPromptDescription")} + {t("remoteDesktop.credentialPromptDescription")}
@@ -537,7 +542,7 @@ const GuacamoleAppInner = React.forwardRef<
); @@ -564,11 +569,11 @@ const GuacamoleAppInner = React.forwardRef< {(!isDisplayReady || connectionError) && ( )} @@ -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: diff --git a/src/ui/features/guacamole/GuacamoleDisplay.tsx b/plugins/remote-desktop/frontend/GuacamoleDisplay.tsx similarity index 98% rename from src/ui/features/guacamole/GuacamoleDisplay.tsx rename to plugins/remote-desktop/frontend/GuacamoleDisplay.tsx index 325149c2e..b8f5db6eb 100644 --- a/src/ui/features/guacamole/GuacamoleDisplay.tsx +++ b/plugins/remote-desktop/frontend/GuacamoleDisplay.tsx @@ -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<
- {t("guacamole.files.dropToUpload")} + {t("remoteDesktop.files.dropToUpload")}
)} diff --git a/src/ui/features/guacamole/GuacamoleFileBrowser.tsx b/plugins/remote-desktop/frontend/GuacamoleFileBrowser.tsx similarity index 89% rename from src/ui/features/guacamole/GuacamoleFileBrowser.tsx rename to plugins/remote-desktop/frontend/GuacamoleFileBrowser.tsx index d612f561c..0ae48b7b0 100644 --- a/src/ui/features/guacamole/GuacamoleFileBrowser.tsx +++ b/plugins/remote-desktop/frontend/GuacamoleFileBrowser.tsx @@ -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({
- {t("guacamole.files.title")} + {t("remoteDesktop.files.title")} @@ -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")} > @@ -174,7 +174,7 @@ export function GuacamoleFileBrowser({ )} {!loading && !error && entries.length === 0 && (

- {t("guacamole.files.empty")} + {t("remoteDesktop.files.empty")}

)} {!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")} > @@ -235,8 +235,8 @@ export function GuacamoleFileBrowser({ > {busyName !== null - ? t("guacamole.files.uploading", { name: busyName }) - : t("guacamole.files.upload")} + ? t("remoteDesktop.files.uploading", { name: busyName }) + : t("remoteDesktop.files.upload")}
)} diff --git a/src/ui/tests/features/guacamole/GuacamoleToolbar.test.tsx b/plugins/remote-desktop/frontend/GuacamoleToolbar.test.tsx similarity index 90% rename from src/ui/tests/features/guacamole/GuacamoleToolbar.test.tsx rename to plugins/remote-desktop/frontend/GuacamoleToolbar.test.tsx index 8ff7448b2..a9eed57ad 100644 --- a/src/ui/tests/features/guacamole/GuacamoleToolbar.test.tsx +++ b/plugins/remote-desktop/frontend/GuacamoleToolbar.test.tsx @@ -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", () => { , ); - 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(); }); diff --git a/src/ui/features/guacamole/GuacamoleToolbar.tsx b/plugins/remote-desktop/frontend/GuacamoleToolbar.tsx similarity index 89% rename from src/ui/features/guacamole/GuacamoleToolbar.tsx rename to plugins/remote-desktop/frontend/GuacamoleToolbar.tsx index 9c366df27..94e7597d0 100644 --- a/src/ui/features/guacamole/GuacamoleToolbar.tsx +++ b/plugins/remote-desktop/frontend/GuacamoleToolbar.tsx @@ -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; @@ -302,7 +302,7 @@ export const GuacamoleToolbar: React.FC = ({
- {t("guacamole.toolbar.expand")} + {t("remoteDesktop.toolbar.expand")} ) : ( @@ -319,7 +319,7 @@ export const GuacamoleToolbar: React.FC = ({ - {t("guacamole.toolbar.dragHandle")} + {t("remoteDesktop.toolbar.dragHandle")} @@ -349,8 +349,8 @@ export const GuacamoleToolbar: React.FC = ({ {touchMode === "touchscreen" - ? t("guacamole.toolbar.switchToTrackpad") - : t("guacamole.toolbar.switchToTouch")} + ? t("remoteDesktop.toolbar.switchToTrackpad") + : t("remoteDesktop.toolbar.switchToTouch")} @@ -361,7 +361,7 @@ export const GuacamoleToolbar: React.FC = ({ <>
@@ -374,26 +374,26 @@ export const GuacamoleToolbar: React.FC = ({ <>
displayRef.current?.zoomOut()} > displayRef.current?.resetZoom()} className="min-w-12 tabular-nums" > {Math.round(zoom * 100)}% displayRef.current?.zoomIn()} > displayRef.current?.resetZoom()} > @@ -406,7 +406,7 @@ export const GuacamoleToolbar: React.FC = ({ <>
sendCombo(0xffe3, 0xffe9, 0xffff)} > CAD @@ -435,13 +435,17 @@ export const GuacamoleToolbar: React.FC = ({ [ "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 = ({ {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, + })} ))} @@ -473,7 +481,7 @@ export const GuacamoleToolbar: React.FC = ({ {/* Function key toggle */}
setShowFKeys((v) => !v)} className={cn( showFKeys && @@ -498,37 +506,37 @@ export const GuacamoleToolbar: React.FC = ({ {/* Navigation */}
sendCombo(0xff1b)} > Esc sendCombo(0xff09)} > Tab sendCombo(0xff50)} > Home sendCombo(0xff57)} > End sendCombo(0xff55)} > PgUp sendCombo(0xff56)} > PgDn @@ -538,7 +546,7 @@ export const GuacamoleToolbar: React.FC = ({
sendCombo(0xff52)} > @@ -546,19 +554,19 @@ export const GuacamoleToolbar: React.FC = ({
sendCombo(0xff51)} > sendCombo(0xff54)} > sendCombo(0xff53)} > @@ -579,12 +587,12 @@ export const GuacamoleToolbar: React.FC = ({ - {t("guacamole.toolbar.collapse")} + {t("remoteDesktop.toolbar.collapse")} {onHide && ( diff --git a/src/ui/tests/api/guacamole-api.test.ts b/plugins/remote-desktop/frontend/guacamole-api.test.ts similarity index 99% rename from src/ui/tests/api/guacamole-api.test.ts rename to plugins/remote-desktop/frontend/guacamole-api.test.ts index 52b46d74a..a55a6585d 100644 --- a/src/ui/tests/api/guacamole-api.test.ts +++ b/plugins/remote-desktop/frontend/guacamole-api.test.ts @@ -25,7 +25,7 @@ import { getGuacdStatus, getGuacamoleConnectionId, getGuacamoleTokenFromHost, -} from "../../api/guacamole-api"; +} from "./guacamole-api"; beforeEach(() => { authApiMock.get.mockClear(); diff --git a/src/ui/api/guacamole-api.ts b/plugins/remote-desktop/frontend/guacamole-api.ts similarity index 100% rename from src/ui/api/guacamole-api.ts rename to plugins/remote-desktop/frontend/guacamole-api.ts diff --git a/src/ui/tests/features/guacamole/guacamole-clipboard.test.ts b/plugins/remote-desktop/frontend/guacamole-clipboard.test.ts similarity index 95% rename from src/ui/tests/features/guacamole/guacamole-clipboard.test.ts rename to plugins/remote-desktop/frontend/guacamole-clipboard.test.ts index 8b0db5375..b6e4f690f 100644 --- a/src/ui/tests/features/guacamole/guacamole-clipboard.test.ts +++ b/plugins/remote-desktop/frontend/guacamole-clipboard.test.ts @@ -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", () => { diff --git a/src/ui/features/guacamole/guacamole-clipboard.ts b/plugins/remote-desktop/frontend/guacamole-clipboard.ts similarity index 100% rename from src/ui/features/guacamole/guacamole-clipboard.ts rename to plugins/remote-desktop/frontend/guacamole-clipboard.ts diff --git a/src/ui/tests/features/guacamole/guacamole-display-size.test.ts b/plugins/remote-desktop/frontend/guacamole-display-size.test.ts similarity index 97% rename from src/ui/tests/features/guacamole/guacamole-display-size.test.ts rename to plugins/remote-desktop/frontend/guacamole-display-size.test.ts index 71c336bf8..4d4ee897e 100644 --- a/src/ui/tests/features/guacamole/guacamole-display-size.test.ts +++ b/plugins/remote-desktop/frontend/guacamole-display-size.test.ts @@ -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", () => { diff --git a/src/ui/features/guacamole/guacamole-display-size.ts b/plugins/remote-desktop/frontend/guacamole-display-size.ts similarity index 100% rename from src/ui/features/guacamole/guacamole-display-size.ts rename to plugins/remote-desktop/frontend/guacamole-display-size.ts diff --git a/src/ui/tests/features/guacamole/guacamole-file-drop.test.ts b/plugins/remote-desktop/frontend/guacamole-file-drop.test.ts similarity index 94% rename from src/ui/tests/features/guacamole/guacamole-file-drop.test.ts rename to plugins/remote-desktop/frontend/guacamole-file-drop.test.ts index e7728c998..6d40a966f 100644 --- a/src/ui/tests/features/guacamole/guacamole-file-drop.test.ts +++ b/plugins/remote-desktop/frontend/guacamole-file-drop.test.ts @@ -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", () => { diff --git a/src/ui/features/guacamole/guacamole-file-drop.ts b/plugins/remote-desktop/frontend/guacamole-file-drop.ts similarity index 100% rename from src/ui/features/guacamole/guacamole-file-drop.ts rename to plugins/remote-desktop/frontend/guacamole-file-drop.ts diff --git a/src/ui/features/guacamole/guacamole-filesystem.test.ts b/plugins/remote-desktop/frontend/guacamole-filesystem.test.ts similarity index 100% rename from src/ui/features/guacamole/guacamole-filesystem.test.ts rename to plugins/remote-desktop/frontend/guacamole-filesystem.test.ts diff --git a/src/ui/features/guacamole/guacamole-filesystem.ts b/plugins/remote-desktop/frontend/guacamole-filesystem.ts similarity index 100% rename from src/ui/features/guacamole/guacamole-filesystem.ts rename to plugins/remote-desktop/frontend/guacamole-filesystem.ts diff --git a/src/ui/tests/features/guacamole/guacamole-meta-key.test.ts b/plugins/remote-desktop/frontend/guacamole-meta-key.test.ts similarity index 92% rename from src/ui/tests/features/guacamole/guacamole-meta-key.test.ts rename to plugins/remote-desktop/frontend/guacamole-meta-key.test.ts index 0032250e9..72a8e2f1e 100644 --- a/src/ui/tests/features/guacamole/guacamole-meta-key.test.ts +++ b/plugins/remote-desktop/frontend/guacamole-meta-key.test.ts @@ -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", () => { diff --git a/src/ui/features/guacamole/guacamole-meta-key.ts b/plugins/remote-desktop/frontend/guacamole-meta-key.ts similarity index 80% rename from src/ui/features/guacamole/guacamole-meta-key.ts rename to plugins/remote-desktop/frontend/guacamole-meta-key.ts index b3bfdcd0c..a9cc446d2 100644 --- a/src/ui/features/guacamole/guacamole-meta-key.ts +++ b/plugins/remote-desktop/frontend/guacamole-meta-key.ts @@ -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", }; } diff --git a/src/ui/tests/features/guacamole/guacamole-pointer.test.ts b/plugins/remote-desktop/frontend/guacamole-pointer.test.ts similarity index 96% rename from src/ui/tests/features/guacamole/guacamole-pointer.test.ts rename to plugins/remote-desktop/frontend/guacamole-pointer.test.ts index c15acb745..96effd4e5 100644 --- a/src/ui/tests/features/guacamole/guacamole-pointer.test.ts +++ b/plugins/remote-desktop/frontend/guacamole-pointer.test.ts @@ -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; diff --git a/src/ui/features/guacamole/guacamole-pointer.ts b/plugins/remote-desktop/frontend/guacamole-pointer.ts similarity index 100% rename from src/ui/features/guacamole/guacamole-pointer.ts rename to plugins/remote-desktop/frontend/guacamole-pointer.ts diff --git a/src/ui/tests/features/guacamole/guacamole-scroll.test.ts b/plugins/remote-desktop/frontend/guacamole-scroll.test.ts similarity index 97% rename from src/ui/tests/features/guacamole/guacamole-scroll.test.ts rename to plugins/remote-desktop/frontend/guacamole-scroll.test.ts index 895e46a1f..15634717f 100644 --- a/src/ui/tests/features/guacamole/guacamole-scroll.test.ts +++ b/plugins/remote-desktop/frontend/guacamole-scroll.test.ts @@ -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", () => { diff --git a/src/ui/features/guacamole/guacamole-scroll.ts b/plugins/remote-desktop/frontend/guacamole-scroll.ts similarity index 100% rename from src/ui/features/guacamole/guacamole-scroll.ts rename to plugins/remote-desktop/frontend/guacamole-scroll.ts diff --git a/src/ui/tests/features/guacamole/guacamole-session-id.test.ts b/plugins/remote-desktop/frontend/guacamole-session-id.test.ts similarity index 89% rename from src/ui/tests/features/guacamole/guacamole-session-id.test.ts rename to plugins/remote-desktop/frontend/guacamole-session-id.test.ts index 4acb9cd5e..248382ab3 100644 --- a/src/ui/tests/features/guacamole/guacamole-session-id.test.ts +++ b/plugins/remote-desktop/frontend/guacamole-session-id.test.ts @@ -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(); diff --git a/src/ui/features/guacamole/guacamole-session-id.ts b/plugins/remote-desktop/frontend/guacamole-session-id.ts similarity index 92% rename from src/ui/features/guacamole/guacamole-session-id.ts rename to plugins/remote-desktop/frontend/guacamole-session-id.ts index 90a9ffc0b..268ab5f8b 100644 --- a/src/ui/features/guacamole/guacamole-session-id.ts +++ b/plugins/remote-desktop/frontend/guacamole-session-id.ts @@ -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( diff --git a/src/ui/tests/features/guacamole/GuacamoleDisplay.test.ts b/plugins/remote-desktop/frontend/guacamole-websocket-url.test.ts similarity index 94% rename from src/ui/tests/features/guacamole/GuacamoleDisplay.test.ts rename to plugins/remote-desktop/frontend/guacamole-websocket-url.test.ts index 24cea104b..17e07de82 100644 --- a/src/ui/tests/features/guacamole/GuacamoleDisplay.test.ts +++ b/plugins/remote-desktop/frontend/guacamole-websocket-url.test.ts @@ -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:", diff --git a/src/ui/features/guacamole/guacamole-websocket-url.ts b/plugins/remote-desktop/frontend/guacamole-websocket-url.ts similarity index 100% rename from src/ui/features/guacamole/guacamole-websocket-url.ts rename to plugins/remote-desktop/frontend/guacamole-websocket-url.ts diff --git a/src/ui/features/guacamole/guacamole-zoom.test.ts b/plugins/remote-desktop/frontend/guacamole-zoom.test.ts similarity index 100% rename from src/ui/features/guacamole/guacamole-zoom.test.ts rename to plugins/remote-desktop/frontend/guacamole-zoom.test.ts diff --git a/src/ui/features/guacamole/guacamole-zoom.ts b/plugins/remote-desktop/frontend/guacamole-zoom.ts similarity index 100% rename from src/ui/features/guacamole/guacamole-zoom.ts rename to plugins/remote-desktop/frontend/guacamole-zoom.ts diff --git a/plugins/remote-desktop/frontend/index.mjs b/plugins/remote-desktop/frontend/index.mjs new file mode 100644 index 000000000..f2edaadc6 --- /dev/null +++ b/plugins/remote-desktop/frontend/index.mjs @@ -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. +} diff --git a/src/ui/tests/features/guacamole/rdp-credential-prompt.test.ts b/plugins/remote-desktop/frontend/rdp-credential-prompt.test.ts similarity index 89% rename from src/ui/tests/features/guacamole/rdp-credential-prompt.test.ts rename to plugins/remote-desktop/frontend/rdp-credential-prompt.test.ts index ff8926838..f9afa67dd 100644 --- a/src/ui/tests/features/guacamole/rdp-credential-prompt.test.ts +++ b/plugins/remote-desktop/frontend/rdp-credential-prompt.test.ts @@ -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", () => { diff --git a/src/ui/features/guacamole/rdp-credential-prompt.ts b/plugins/remote-desktop/frontend/rdp-credential-prompt.ts similarity index 100% rename from src/ui/features/guacamole/rdp-credential-prompt.ts rename to plugins/remote-desktop/frontend/rdp-credential-prompt.ts diff --git a/plugins/remote-desktop/manifest.json b/plugins/remote-desktop/manifest.json new file mode 100644 index 000000000..e6d349f6c --- /dev/null +++ b/plugins/remote-desktop/manifest.json @@ -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": [] +} diff --git a/src/backend/database/routes/guacamole-dispatch.ts b/src/backend/database/routes/guacamole-dispatch.ts new file mode 100644 index 000000000..e7ed1b7b9 --- /dev/null +++ b/src/backend/database/routes/guacamole-dispatch.ts @@ -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; + 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 { + 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); +} diff --git a/src/backend/tests/plugins/remote-desktop/drive-settings.test.ts b/src/backend/tests/plugins/remote-desktop/drive-settings.test.ts new file mode 100644 index 000000000..0a8069d65 --- /dev/null +++ b/src/backend/tests/plugins/remote-desktop/drive-settings.test.ts @@ -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); + }); +}); diff --git a/src/backend/tests/plugins/remote-desktop/jump-tunnel-endpoint.test.ts b/src/backend/tests/plugins/remote-desktop/jump-tunnel-endpoint.test.ts new file mode 100644 index 000000000..b4528d6f4 --- /dev/null +++ b/src/backend/tests/plugins/remote-desktop/jump-tunnel-endpoint.test.ts @@ -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", + }); + }); +}); diff --git a/src/backend/tests/plugins/remote-desktop/macos-vnc-proxy.test.ts b/src/backend/tests/plugins/remote-desktop/macos-vnc-proxy.test.ts new file mode 100644 index 000000000..7c02d25e3 --- /dev/null +++ b/src/backend/tests/plugins/remote-desktop/macos-vnc-proxy.test.ts @@ -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 { + 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 { + 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]); + }); +}); diff --git a/src/backend/tests/plugins/remote-desktop/rdp-settings.test.ts b/src/backend/tests/plugins/remote-desktop/rdp-settings.test.ts new file mode 100644 index 000000000..c53892bd4 --- /dev/null +++ b/src/backend/tests/plugins/remote-desktop/rdp-settings.test.ts @@ -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"); + }); +}); diff --git a/src/backend/tests/plugins/remote-desktop/recording-settings.test.ts b/src/backend/tests/plugins/remote-desktop/recording-settings.test.ts new file mode 100644 index 000000000..948f150f9 --- /dev/null +++ b/src/backend/tests/plugins/remote-desktop/recording-settings.test.ts @@ -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" }); + }); +}); diff --git a/src/backend/tests/plugins/remote-desktop/token-delivery.test.ts b/src/backend/tests/plugins/remote-desktop/token-delivery.test.ts new file mode 100644 index 000000000..b7829249b --- /dev/null +++ b/src/backend/tests/plugins/remote-desktop/token-delivery.test.ts @@ -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 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(); +}); diff --git a/src/backend/tests/plugins/remote-desktop/token-service.test.ts b/src/backend/tests/plugins/remote-desktop/token-service.test.ts new file mode 100644 index 000000000..998c5cd09 --- /dev/null +++ b/src/backend/tests/plugins/remote-desktop/token-service.test.ts @@ -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(); + }); +}); diff --git a/src/ui/tests/features/guacamole/guacamole-filesystem.test.ts b/src/ui/tests/features/guacamole/guacamole-filesystem.test.ts deleted file mode 100644 index 1abc5bf50..000000000 --- a/src/ui/tests/features/guacamole/guacamole-filesystem.test.ts +++ /dev/null @@ -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", - ); - }); -});