fix: upload files to redirected RDP drives (#1356)

This commit is contained in:
ZacharyZcR
2026-08-28 10:36:54 +08:00
committed by GitHub
parent bf67f56c51
commit 0f39ce6369
8 changed files with 141 additions and 15 deletions
+9 -2
View File
@@ -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
+37 -7
View File
@@ -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);
@@ -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<void>;
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),
@@ -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);
}
@@ -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<void> {
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<void> {
return writeUpload(createClientFileStream(client, file), file, onProgress);
}
export function createClientFileStream(
client: GuacamoleFileStreamClient,
file: File,
): Guacamole.OutputStream {
return client.createFileStream(
file.type || "application/octet-stream",
file.name,
);
}
function writeUpload(
stream: Guacamole.OutputStream,
file: File,
onProgress?: (sent: number, total: number) => void,
): Promise<void> {
return new Promise((resolve, reject) => {
const writer = new Guacamole.BlobWriter(stream);
// A rejected blob stops the writer without firing onerror or oncomplete —
@@ -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,
@@ -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);
});
});
@@ -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",
);
});
});