mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 23:55:23 +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,223 @@
|
||||
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 =
|
||||
"application/vnd.glyptodon.guacamole.stream-index+json";
|
||||
|
||||
export interface RemoteFileEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
mimetype: string;
|
||||
isDirectory: boolean;
|
||||
}
|
||||
|
||||
export function isDirectoryMimetype(mimetype: string): boolean {
|
||||
return mimetype === STREAM_INDEX_MIMETYPE;
|
||||
}
|
||||
|
||||
export function joinPath(parent: string, name: string): string {
|
||||
return parent === "/" ? `/${name}` : `${parent}/${name}`;
|
||||
}
|
||||
|
||||
export function parentPath(path: string): string {
|
||||
const cut = path.lastIndexOf("/");
|
||||
return cut <= 0 ? "/" : path.slice(0, cut);
|
||||
}
|
||||
|
||||
export function basename(path: string): string {
|
||||
return path.slice(path.lastIndexOf("/") + 1) || path;
|
||||
}
|
||||
|
||||
// guacd returns absolute paths as keys, but a malformed or relative key would
|
||||
// otherwise produce a broken path on the next descent.
|
||||
export function parseDirectoryIndex(
|
||||
json: string,
|
||||
path: string,
|
||||
): RemoteFileEntry[] {
|
||||
const index = JSON.parse(json) as Record<string, string>;
|
||||
|
||||
return Object.entries(index)
|
||||
.map(([key, mimetype]) => {
|
||||
const name = basename(key);
|
||||
return {
|
||||
name,
|
||||
path: key.startsWith("/") ? key : joinPath(path, name),
|
||||
mimetype,
|
||||
isDirectory: isDirectoryMimetype(mimetype),
|
||||
};
|
||||
})
|
||||
.sort((a, b) =>
|
||||
a.isDirectory === b.isDirectory
|
||||
? a.name.localeCompare(b.name)
|
||||
: a.isDirectory
|
||||
? -1
|
||||
: 1,
|
||||
);
|
||||
}
|
||||
|
||||
// A refused stream is answered with an error ack that never reaches the body
|
||||
// callback, so every request needs its own deadline to avoid hanging forever.
|
||||
const REQUEST_TIMEOUT_MS = 15000;
|
||||
|
||||
function requestStream(
|
||||
filesystem: Guacamole.Object,
|
||||
path: string,
|
||||
): Promise<{ stream: Guacamole.InputStream; mimetype: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(
|
||||
() => reject(new Error(`Timed out reading ${path}`)),
|
||||
REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
filesystem.requestInputStream(path, (stream, mimetype) => {
|
||||
clearTimeout(timeout);
|
||||
resolve({ stream, mimetype });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function listDirectory(
|
||||
filesystem: Guacamole.Object,
|
||||
path: string,
|
||||
): Promise<RemoteFileEntry[]> {
|
||||
const { stream, mimetype } = await requestStream(filesystem, path);
|
||||
|
||||
if (!isDirectoryMimetype(mimetype)) {
|
||||
throw new Error(`${path} is not a directory`);
|
||||
}
|
||||
|
||||
const json = await new Promise<string>((resolve) => {
|
||||
const reader = new Guacamole.StringReader(stream);
|
||||
let text = "";
|
||||
reader.ontext = (chunk: string) => {
|
||||
text += chunk;
|
||||
};
|
||||
reader.onend = () => resolve(text);
|
||||
});
|
||||
|
||||
return parseDirectoryIndex(json, path);
|
||||
}
|
||||
|
||||
export async function downloadFile(
|
||||
filesystem: Guacamole.Object,
|
||||
path: string,
|
||||
): Promise<{ blob: Blob; filename: string }> {
|
||||
const { stream, mimetype } = await requestStream(filesystem, path);
|
||||
|
||||
if (isDirectoryMimetype(mimetype)) {
|
||||
throw new Error(`${path} is a directory`);
|
||||
}
|
||||
|
||||
const blob = await new Promise<Blob>((resolve) => {
|
||||
const reader = new Guacamole.BlobReader(stream, mimetype);
|
||||
reader.onend = () => resolve(reader.getBlob());
|
||||
});
|
||||
|
||||
return { blob, filename: basename(path) };
|
||||
}
|
||||
|
||||
export function uploadFile(
|
||||
filesystem: Guacamole.Object,
|
||||
directory: string,
|
||||
file: File,
|
||||
onProgress?: (sent: number, total: number) => void,
|
||||
): Promise<void> {
|
||||
return writeUpload(
|
||||
filesystem.createOutputStream(
|
||||
file.type || "application/octet-stream",
|
||||
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 —
|
||||
// only the error ack reports it, so without this the upload hangs forever.
|
||||
writer.onack = (status: Guacamole.Status) => {
|
||||
if (status.isError()) {
|
||||
reject(
|
||||
new Error(
|
||||
status.message || `Server refused the upload of ${file.name}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
writer.onerror = () => reject(new Error(`Failed to upload ${file.name}`));
|
||||
writer.onprogress = (_blob: Blob, offset: number) =>
|
||||
onProgress?.(offset, file.size);
|
||||
writer.oncomplete = () => {
|
||||
writer.sendEnd();
|
||||
onProgress?.(file.size, file.size);
|
||||
resolve();
|
||||
};
|
||||
|
||||
writer.sendBlob(file);
|
||||
});
|
||||
}
|
||||
|
||||
export function saveBlobAs(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns an upload failure into something a user can act on. guacd answers a
|
||||
* refused stream with an ack like "FAIL (CANNOT OPEN)" - accurate, but it
|
||||
* means "the drive folder isn't writable", which is what people need to hear.
|
||||
*/
|
||||
export function describeUploadError(
|
||||
error: unknown,
|
||||
t: (key: "driveNotWritable" | "driveUnavailable" | "uploadFailed") => string,
|
||||
): string {
|
||||
const message = error instanceof Error ? error.message : "";
|
||||
if (/cannot open|can't open|permission denied/i.test(message)) {
|
||||
return t("driveNotWritable");
|
||||
}
|
||||
if (/no fs/i.test(message)) {
|
||||
return t("driveUnavailable");
|
||||
}
|
||||
return message || t("uploadFailed");
|
||||
}
|
||||
Reference in new issue
Block a user