diff --git a/README.md b/README.md index e218cc26..9455ed0b 100644 --- a/README.md +++ b/README.md @@ -331,6 +331,11 @@ services: - termix-data:/app/data environment: PORT: "8080" + GUACD_HOST: "guacd" + GUACD_TUNNEL_HOST: "termix" + GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole" + # guacd, not the Termix container, reads and writes redirected-drive files. + GUACD_DRIVE_PATH: "/termix-data/rdp-drive" depends_on: - guacd networks: @@ -340,8 +345,10 @@ services: image: guacamole/guacd:1.6.0 container_name: guacd restart: unless-stopped - ports: - - "4822:4822" + volumes: + # The official guacd image runs as a non-root user. Keep the drive path + # in this writable shared volume instead of bind-mounting /drive. + - termix-data:/termix-data networks: - termix-net diff --git a/src/ui/features/guacamole/GuacamoleApp.tsx b/src/ui/features/guacamole/GuacamoleApp.tsx index eb6f2758..f8310d8b 100644 --- a/src/ui/features/guacamole/GuacamoleApp.tsx +++ b/src/ui/features/guacamole/GuacamoleApp.tsx @@ -26,6 +26,8 @@ import { resolveConnectionOrigin } from "@/lib/connection-origin.ts"; import { useTranslation } from "react-i18next"; import { GuacamoleToolbar } from "@/features/guacamole/GuacamoleToolbar.tsx"; import { GuacamoleFileBrowser } from "@/features/guacamole/GuacamoleFileBrowser.tsx"; +import { describeUploadError } from "@/features/guacamole/guacamole-filesystem.ts"; +import { canUploadToRdpDrive } from "@/features/guacamole/guacamole-file-drop.ts"; import { Button } from "@/components/button.tsx"; import { Input } from "@/components/input.tsx"; import { PasswordInput } from "@/components/password-input.tsx"; @@ -212,12 +214,36 @@ const GuacamoleAppInner = React.forwardRef< const allowUpload = guacConfig.disableUpload !== true; const allowDownload = guacConfig.disableDownload !== true; - // A dropped file has nowhere to go until the browser is showing the target - // directory, so opening it is part of accepting the drop. - const handleDropFiles = useCallback((files: File[]) => { - setPendingUploads(files); - setFileBrowserOpen(true); - }, []); + // Prefer the browsable filesystem's current directory. guacd may expose the + // RDP drive only through the connection-level file stream, in which case the + // standard direct upload still lands in the redirected drive. + const handleDropFiles = useCallback( + (files: File[]) => { + if (filesystem) { + setPendingUploads(files); + setFileBrowserOpen(true); + return; + } + + void (async () => { + for (const file of files) { + try { + const display = displayRef.current; + if (!display) throw new Error("RDP session is not ready"); + await display.uploadFile(file); + toast.success(t("guacamole.files.uploaded", { name: file.name })); + } catch (error) { + toast.error( + describeUploadError(error, (key) => + t(`guacamole.files.${key}`, { name: file.name }), + ), + ); + } + } + })(); + }, + [filesystem, t], + ); const handleDropUnavailable = useCallback(() => { toast.error( @@ -526,7 +552,11 @@ const GuacamoleAppInner = React.forwardRef< }} isVisible={isVisible} touchMode={touchMode} - allowUpload={allowUpload && filesystem !== null} + allowUpload={canUploadToRdpDrive( + allowUpload, + guacConfig.enableDrive === true, + filesystem !== null, + )} onConnect={() => setIsDisplayReady(true)} onError={(err) => { setConnectionError(err); diff --git a/src/ui/features/guacamole/GuacamoleDisplay.tsx b/src/ui/features/guacamole/GuacamoleDisplay.tsx index 14fab6a9..5f6605ab 100644 --- a/src/ui/features/guacamole/GuacamoleDisplay.tsx +++ b/src/ui/features/guacamole/GuacamoleDisplay.tsx @@ -25,6 +25,10 @@ import { getFileDropDisposition, hasDraggedFiles, } from "./guacamole-file-drop.ts"; +import { + uploadFileToClient, + type GuacamoleFileStreamClient, +} from "./guacamole-filesystem.ts"; import { guacStateToStage } from "@/components/connection/connection-status.ts"; import type { ConnectionStage } from "@/types/connection-log.ts"; import { clampGuacamoleZoom, stepGuacamoleZoom } from "./guacamole-zoom.ts"; @@ -53,6 +57,7 @@ export interface GuacamoleDisplayHandle { sendMouse: (x: number, y: number, buttonMask: number) => void; setClipboard: (data: string) => void; getFilesystem: () => Guacamole.Object | null; + uploadFile: (file: File) => Promise; zoomIn: () => number; zoomOut: () => number; resetZoom: () => number; @@ -193,6 +198,14 @@ export const GuacamoleDisplay = forwardRef< } }, getFilesystem: () => filesystemRef.current, + uploadFile: (file: File) => { + const client = clientRef.current; + if (!client) return Promise.reject(new Error("RDP session is not ready")); + return uploadFileToClient( + client as unknown as GuacamoleFileStreamClient, + file, + ); + }, zoomIn: () => applyZoom(stepGuacamoleZoom(zoomRef.current, 1)), zoomOut: () => applyZoom(stepGuacamoleZoom(zoomRef.current, -1)), resetZoom: () => applyZoom(1), diff --git a/src/ui/features/guacamole/guacamole-file-drop.ts b/src/ui/features/guacamole/guacamole-file-drop.ts index 07b0c8ee..b1fe2273 100644 --- a/src/ui/features/guacamole/guacamole-file-drop.ts +++ b/src/ui/features/guacamole/guacamole-file-drop.ts @@ -12,3 +12,11 @@ export function getFileDropDisposition( if (!hasDraggedFiles(types) || fileCount === 0) return "ignore"; return canUpload ? "upload" : "reject"; } + +export function canUploadToRdpDrive( + uploadAllowed: boolean, + driveEnabled: boolean, + hasFilesystem: boolean, +): boolean { + return uploadAllowed && (driveEnabled || hasFilesystem); +} diff --git a/src/ui/features/guacamole/guacamole-filesystem.ts b/src/ui/features/guacamole/guacamole-filesystem.ts index 5504c047..a0ae0a1c 100644 --- a/src/ui/features/guacamole/guacamole-filesystem.ts +++ b/src/ui/features/guacamole/guacamole-filesystem.ts @@ -1,5 +1,11 @@ import Guacamole from "guacamole-common-js"; +// The bundled Guacamole runtime exposes Client.createFileStream(), but the +// package's older TypeScript declaration omits it. +export interface GuacamoleFileStreamClient { + createFileStream(mimetype: string, filename: string): Guacamole.OutputStream; +} + // The root stream of a Guacamole.Object maps stream name to mimetype; a stream // carrying that same mimetype is itself a directory. export const STREAM_INDEX_MIMETYPE = @@ -123,12 +129,45 @@ export function uploadFile( file: File, onProgress?: (sent: number, total: number) => void, ): Promise { - return new Promise((resolve, reject) => { - const path = joinPath(directory, file.name); - const stream = filesystem.createOutputStream( + return writeUpload( + filesystem.createOutputStream( file.type || "application/octet-stream", - path, - ); + joinPath(directory, file.name), + ), + file, + onProgress, + ); +} + +/** + * Uploads through Guacamole's connection-level file stream. RDP exposes this + * path even when guacd does not advertise a browsable filesystem object, so + * display drops must not depend on `Client.onfilesystem` having fired. + */ +export function uploadFileToClient( + client: GuacamoleFileStreamClient, + file: File, + onProgress?: (sent: number, total: number) => void, +): Promise { + return writeUpload(createClientFileStream(client, file), file, onProgress); +} + +export function createClientFileStream( + client: GuacamoleFileStreamClient, + file: File, +): Guacamole.OutputStream { + return client.createFileStream( + file.type || "application/octet-stream", + file.name, + ); +} + +function writeUpload( + stream: Guacamole.OutputStream, + file: File, + onProgress?: (sent: number, total: number) => void, +): Promise { + return new Promise((resolve, reject) => { const writer = new Guacamole.BlobWriter(stream); // A rejected blob stops the writer without firing onerror or oncomplete — diff --git a/src/ui/tests/features/guacamole/GuacamoleToolbar.test.tsx b/src/ui/tests/features/guacamole/GuacamoleToolbar.test.tsx index 7760e897..c58dc1f9 100644 --- a/src/ui/tests/features/guacamole/GuacamoleToolbar.test.tsx +++ b/src/ui/tests/features/guacamole/GuacamoleToolbar.test.tsx @@ -19,6 +19,7 @@ describe("GuacamoleToolbar Windows key", () => { sendMouse: vi.fn(), setClipboard: vi.fn(), getFilesystem: () => null, + uploadFile: async () => {}, zoomIn: vi.fn(() => 1.25), zoomOut: vi.fn(() => 0.75), resetZoom: vi.fn(() => 1), @@ -60,6 +61,7 @@ describe("GuacamoleToolbar Windows key", () => { sendMouse: vi.fn(), setClipboard: vi.fn(), getFilesystem: () => null, + uploadFile: async () => {}, zoomIn, zoomOut, resetZoom, diff --git a/src/ui/tests/features/guacamole/guacamole-file-drop.test.ts b/src/ui/tests/features/guacamole/guacamole-file-drop.test.ts index dd2968dd..e7728c99 100644 --- a/src/ui/tests/features/guacamole/guacamole-file-drop.test.ts +++ b/src/ui/tests/features/guacamole/guacamole-file-drop.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + canUploadToRdpDrive, getFileDropDisposition, hasDraggedFiles, } from "@/features/guacamole/guacamole-file-drop.ts"; @@ -14,4 +15,11 @@ describe("Guacamole file drop", () => { expect(getFileDropDisposition(["Files"], 1, false)).toBe("reject"); expect(getFileDropDisposition(["Files"], 1, true)).toBe("upload"); }); + + it("accepts an enabled RDP drive before a filesystem object is advertised", () => { + expect(canUploadToRdpDrive(true, true, false)).toBe(true); + expect(canUploadToRdpDrive(true, false, true)).toBe(true); + expect(canUploadToRdpDrive(true, false, false)).toBe(false); + expect(canUploadToRdpDrive(false, true, true)).toBe(false); + }); }); diff --git a/src/ui/tests/features/guacamole/guacamole-filesystem.test.ts b/src/ui/tests/features/guacamole/guacamole-filesystem.test.ts index 3fe20f89..1abc5bf5 100644 --- a/src/ui/tests/features/guacamole/guacamole-filesystem.test.ts +++ b/src/ui/tests/features/guacamole/guacamole-filesystem.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { STREAM_INDEX_MIMETYPE, basename, @@ -6,7 +6,10 @@ import { 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", () => { @@ -86,3 +89,19 @@ describe("parseDirectoryIndex", () => { 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", + ); + }); +});