mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
feat: move remote desktop (RDP/VNC/Telnet) into a first-party plugin
Relocates guacamole backend/frontend source and tests into plugins/remote-desktop, following the ssh-terminal/docker pattern.
This commit is contained in:
1 parent
d440ce9073
commit
c6bcfdb80d
50 files changed
+1031
-232
No files matched your search
@@ -1,254 +0,0 @@
|
||||
import {
|
||||
authApi,
|
||||
getRemoteGuacamoleApi,
|
||||
handleApiError,
|
||||
isElectron,
|
||||
} from "@/main-axios";
|
||||
import type { AxiosInstance } from "axios";
|
||||
import type { GuacamoleConfig } from "@/types/guacamole-config";
|
||||
import { resolveRemoteHostId } from "@/lib/remote-server-api";
|
||||
import type { ConnectionOrigin } from "@/lib/connection-origin";
|
||||
|
||||
/**
|
||||
* Picks the backend that will actually serve this session, so the token and
|
||||
* status come from the same place as the connection.
|
||||
*
|
||||
* Outside Electron there is only one backend. Inside it, a host left on
|
||||
* Default still resolves to the connected server -- asking the embedded
|
||||
* backend would report the guacd *it* can reach rather than the one serving
|
||||
* the session. A host explicitly set to "This Device" (Support#1240) is
|
||||
* served by the embedded backend against the user's own guacd, so its calls
|
||||
* have to go there instead.
|
||||
*
|
||||
* Callers pass the origin explicitly rather than defaulting it: a default
|
||||
* silently sent a missed call site to the remote server, which fails with a
|
||||
* bare network error on a desktop that has none configured.
|
||||
*/
|
||||
function guacamoleApi(origin: ConnectionOrigin): AxiosInstance {
|
||||
if (!isElectron()) return authApi;
|
||||
return origin === "local" ? authApi : getRemoteGuacamoleApi();
|
||||
}
|
||||
|
||||
export interface GuacamoleTokenRequest {
|
||||
protocol: "rdp" | "vnc" | "telnet";
|
||||
hostname: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
domain?: string;
|
||||
security?: string;
|
||||
ignoreCert?: boolean;
|
||||
guacamoleConfig?: GuacamoleConfig;
|
||||
}
|
||||
|
||||
export interface GuacamoleTokenResponse {
|
||||
token: string;
|
||||
guacamoleConnectionId?: string | null;
|
||||
termixConnectId?: string;
|
||||
}
|
||||
|
||||
type GuacamoleConfigSource = {
|
||||
guacamoleConfig?: string | Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export function parseGuacamoleConfig(
|
||||
config?: string | GuacamoleConfig | null,
|
||||
): GuacamoleConfig {
|
||||
if (!config) return {};
|
||||
if (typeof config !== "string") return config;
|
||||
try {
|
||||
return JSON.parse(config) as GuacamoleConfig;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function getGuacamoleDpi(
|
||||
source?: GuacamoleConfigSource,
|
||||
): number | undefined {
|
||||
const config = source?.guacamoleConfig;
|
||||
if (!config) return undefined;
|
||||
|
||||
let dpi: unknown;
|
||||
if (typeof config === "string") {
|
||||
try {
|
||||
dpi = JSON.parse(config).dpi;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
} else {
|
||||
dpi = config.dpi;
|
||||
}
|
||||
|
||||
const parsedDpi = typeof dpi === "string" ? Number(dpi) : dpi;
|
||||
if (
|
||||
typeof parsedDpi !== "number" ||
|
||||
!Number.isFinite(parsedDpi) ||
|
||||
parsedDpi <= 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return Math.trunc(parsedDpi);
|
||||
}
|
||||
|
||||
function toGuacamoleParams(
|
||||
config: GuacamoleTokenRequest["guacamoleConfig"],
|
||||
): Record<string, unknown> {
|
||||
if (!config) return {};
|
||||
|
||||
const params: Record<string, unknown> = {};
|
||||
|
||||
const mappings: Record<string, string> = {
|
||||
colorDepth: "color-depth",
|
||||
resizeMethod: "resize-method",
|
||||
forceLossless: "force-lossless",
|
||||
disableAudio: "disable-audio",
|
||||
enableAudioInput: "enable-audio-input",
|
||||
enableWallpaper: "enable-wallpaper",
|
||||
enableTheming: "enable-theming",
|
||||
enableFontSmoothing: "enable-font-smoothing",
|
||||
enableFullWindowDrag: "enable-full-window-drag",
|
||||
enableDesktopComposition: "enable-desktop-composition",
|
||||
enableMenuAnimations: "enable-menu-animations",
|
||||
disableBitmapCaching: "disable-bitmap-caching",
|
||||
disableOffscreenCaching: "disable-offscreen-caching",
|
||||
disableGlyphCaching: "disable-glyph-caching",
|
||||
disableGfx: "disable-gfx",
|
||||
enablePrinting: "enable-printing",
|
||||
printerName: "printer-name",
|
||||
enableDrive: "enable-drive",
|
||||
driveName: "drive-name",
|
||||
drivePath: "drive-path",
|
||||
createDrivePath: "create-drive-path",
|
||||
disableDownload: "disable-download",
|
||||
disableUpload: "disable-upload",
|
||||
enableTouch: "enable-touch",
|
||||
clientName: "client-name",
|
||||
initialProgram: "initial-program",
|
||||
serverLayout: "server-layout",
|
||||
gatewayHostname: "gateway-hostname",
|
||||
gatewayPort: "gateway-port",
|
||||
gatewayUsername: "gateway-username",
|
||||
gatewayPassword: "gateway-password",
|
||||
gatewayDomain: "gateway-domain",
|
||||
remoteApp: "remote-app",
|
||||
remoteAppDir: "remote-app-dir",
|
||||
remoteAppArgs: "remote-app-args",
|
||||
normalizeClipboard: "normalize-clipboard",
|
||||
disableCopy: "disable-copy",
|
||||
disablePaste: "disable-paste",
|
||||
swapRedBlue: "swap-red-blue",
|
||||
readOnly: "read-only",
|
||||
recordingPath: "recording-path",
|
||||
recordingName: "recording-name",
|
||||
createRecordingPath: "create-recording-path",
|
||||
recordingExcludeOutput: "recording-exclude-output",
|
||||
recordingExcludeMouse: "recording-exclude-mouse",
|
||||
recordingIncludeKeys: "recording-include-keys",
|
||||
wolSendPacket: "wol-send-packet",
|
||||
wolMacAddr: "wol-mac-addr",
|
||||
wolBroadcastAddr: "wol-broadcast-addr",
|
||||
wolUdpPort: "wol-udp-port",
|
||||
wolWaitTime: "wol-wait-time",
|
||||
};
|
||||
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
const paramName = mappings[key] || key;
|
||||
if (typeof value === "boolean") {
|
||||
params[paramName] = value ? "true" : "false";
|
||||
} else {
|
||||
params[paramName] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
export async function getGuacamoleToken(
|
||||
request: GuacamoleTokenRequest,
|
||||
origin: ConnectionOrigin,
|
||||
): Promise<GuacamoleTokenResponse> {
|
||||
try {
|
||||
const guacParams = toGuacamoleParams(request.guacamoleConfig);
|
||||
|
||||
const response = await guacamoleApi(origin).post("/guacamole/token", {
|
||||
type: request.protocol,
|
||||
hostname: request.hostname,
|
||||
port: request.port,
|
||||
username: request.username,
|
||||
password: request.password,
|
||||
domain: request.domain,
|
||||
security: request.security,
|
||||
"ignore-cert": request.ignoreCert,
|
||||
...guacParams,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get guacamole token");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGuacamoleTokenFromHost(
|
||||
hostId: number,
|
||||
origin: ConnectionOrigin,
|
||||
protocol?: "rdp" | "vnc" | "telnet",
|
||||
promptedCredentials?: {
|
||||
username?: string;
|
||||
password?: string;
|
||||
domain?: string;
|
||||
},
|
||||
syncId?: string | null,
|
||||
): Promise<GuacamoleTokenResponse> {
|
||||
try {
|
||||
// A locally-originated session is served by the embedded backend, which
|
||||
// knows the host by its local id -- remapping it onto the remote
|
||||
// server's id would address the wrong row, or fail outright when no
|
||||
// server is configured at all.
|
||||
const useRemote = isElectron() && origin === "remote";
|
||||
const remoteHostId = useRemote ? await resolveRemoteHostId(syncId) : null;
|
||||
if (useRemote && syncId && remoteHostId === null) {
|
||||
throw new Error("The synced host does not exist on the remote server");
|
||||
}
|
||||
const targetHostId = remoteHostId ?? hostId;
|
||||
const response = await guacamoleApi(origin).post(
|
||||
`/guacamole/connect-host/${targetHostId}`,
|
||||
{
|
||||
...(protocol ? { protocol } : {}),
|
||||
...(promptedCredentials?.username
|
||||
? { promptedUsername: promptedCredentials.username }
|
||||
: {}),
|
||||
...(promptedCredentials?.password
|
||||
? { promptedPassword: promptedCredentials.password }
|
||||
: {}),
|
||||
...(promptedCredentials
|
||||
? { promptedDomain: promptedCredentials.domain ?? "" }
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get guacamole token from host");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGuacdStatus(origin: ConnectionOrigin): Promise<{
|
||||
guacd: { status: string };
|
||||
}> {
|
||||
const response = await guacamoleApi(origin).get("/guacamole/status");
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getGuacamoleConnectionId(
|
||||
connectId: string,
|
||||
origin: ConnectionOrigin,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string | null> {
|
||||
const response = await guacamoleApi(origin).get(
|
||||
`/guacamole/connection/${encodeURIComponent(connectId)}`,
|
||||
{ signal },
|
||||
);
|
||||
return response.data.guacamoleConnectionId ?? null;
|
||||
}
|
||||
@@ -1,669 +0,0 @@
|
||||
import { watchGuacamoleConnectionId } from "./guacamole-session-id";
|
||||
import { getErrorMessage } from "../../lib/error-message.js";
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
} from "react";
|
||||
import type Guacamole from "guacamole-common-js";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
GuacamoleDisplay,
|
||||
type GuacamoleDisplayHandle,
|
||||
type GuacamoleTouchMode,
|
||||
} from "@/features/guacamole/GuacamoleDisplay.tsx";
|
||||
import {
|
||||
getGuacamoleTokenFromHost,
|
||||
getGuacdStatus,
|
||||
getSSHHosts,
|
||||
logActivity,
|
||||
isElectron,
|
||||
} from "@/main-axios.ts";
|
||||
import { readConfiguredDimension } from "@/features/guacamole/guacamole-display-size.ts";
|
||||
import { getGuacamoleToken, parseGuacamoleConfig } from "@/api/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 { Button } from "@/components/button.tsx";
|
||||
import { Input } from "@/components/input.tsx";
|
||||
import { PasswordInput } from "@/components/password-input.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/dialog.tsx";
|
||||
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
|
||||
import {
|
||||
ConnectionLogProvider,
|
||||
useConnectionLog,
|
||||
} from "@/ssh/connection-log/ConnectionLogContext.tsx";
|
||||
import { useConnectionRetry } from "@/lib/useConnectionRetry.ts";
|
||||
import { ShareSessionModal } from "@/features/session-sharing/ShareSessionModal.tsx";
|
||||
import type { SSHHost } from "@/types";
|
||||
import { useConnectionDefaults } from "@/contexts/ConnectionDefaultsContext";
|
||||
import { resolveConnectionDefaults } from "@/lib/connection-defaults";
|
||||
import { needsRdpCredentialPrompt } from "@/features/guacamole/rdp-credential-prompt";
|
||||
|
||||
interface GuacamoleAppProps {
|
||||
hostId?: string;
|
||||
tabId?: string;
|
||||
protocol?: "rdp" | "vnc" | "telnet";
|
||||
isVisible?: boolean;
|
||||
/** A quick-connect host: never saved, so the token is minted from its fields. */
|
||||
quickConnectHost?: GuacamoleQuickHost;
|
||||
}
|
||||
|
||||
/** What GuacamoleApp needs from a host that has no database row. */
|
||||
export type GuacamoleQuickHost = GuacamoleAppInnerProps["hostConfig"] & {
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export interface GuacamoleAppHandle {
|
||||
disconnect: () => void;
|
||||
isConnected: () => boolean;
|
||||
openShareModal: () => void;
|
||||
canShare: () => boolean;
|
||||
}
|
||||
|
||||
const GuacamoleApp = React.forwardRef<GuacamoleAppHandle, GuacamoleAppProps>(
|
||||
function GuacamoleApp(
|
||||
{ hostId, tabId, protocol, isVisible = true, quickConnectHost },
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const defaults = useConnectionDefaults();
|
||||
const [hostConfig, setHostConfig] = useState<GuacamoleQuickHost | null>(
|
||||
null,
|
||||
);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!defaults.ready) return;
|
||||
if (quickConnectHost) {
|
||||
const connectionType = protocol ?? quickConnectHost.connectionType;
|
||||
setHostConfig({
|
||||
...quickConnectHost,
|
||||
guacamoleConfig: resolveConnectionDefaults(
|
||||
connectionType === "rdp" ? defaults.rdp : {},
|
||||
{},
|
||||
),
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!hostId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
getSSHHosts()
|
||||
.then((hosts) => {
|
||||
const host = hosts.find((h) => h.id === parseInt(hostId, 10));
|
||||
if (!host) {
|
||||
setHostConfig(null);
|
||||
return;
|
||||
}
|
||||
const connectionType = protocol ?? host.connectionType;
|
||||
const protocolDefaults = connectionType === "rdp" ? defaults.rdp : {};
|
||||
setHostConfig({
|
||||
...host,
|
||||
guacamoleConfig: resolveConnectionDefaults(
|
||||
protocolDefaults,
|
||||
parseGuacamoleConfig(host.guacamoleConfig),
|
||||
),
|
||||
});
|
||||
})
|
||||
.catch(() => setHostConfig(null))
|
||||
.finally(() => setLoading(false));
|
||||
}, [hostId, protocol, defaults.ready, defaults.rdp, quickConnectHost]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
<ConnectionScreen status="connecting" message={t("common.loading")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hostConfig || !hostId) {
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
<ConnectionScreen
|
||||
status="disconnected"
|
||||
message={t("guacamole.hostNotFound")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ConnectionLogProvider>
|
||||
<GuacamoleAppInner
|
||||
hostId={quickConnectHost ? 0 : parseInt(hostId, 10)}
|
||||
hostConfig={hostConfig}
|
||||
hostName={hostConfig.name || hostConfig.ip || String(hostId ?? "")}
|
||||
tabId={tabId}
|
||||
protocol={protocol}
|
||||
isVisible={isVisible}
|
||||
ref={ref}
|
||||
/>
|
||||
</ConnectionLogProvider>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
interface GuacamoleAppInnerProps {
|
||||
hostId: number;
|
||||
hostConfig: Pick<
|
||||
SSHHost,
|
||||
| "connectionType"
|
||||
| "domain"
|
||||
| "guacamoleConfig"
|
||||
| "rdpAuthType"
|
||||
| "authOverrides"
|
||||
| "syncId"
|
||||
| "connectionOrigin"
|
||||
| "ip"
|
||||
| "rdpPort"
|
||||
| "vncPort"
|
||||
| "rdpUser"
|
||||
| "rdpPassword"
|
||||
| "vncUser"
|
||||
| "vncPassword"
|
||||
> & { enableTerminalToolbar?: boolean };
|
||||
hostName: string;
|
||||
tabId?: string;
|
||||
protocol?: "rdp" | "vnc" | "telnet";
|
||||
isVisible: boolean;
|
||||
}
|
||||
|
||||
const GuacamoleAppInner = React.forwardRef<
|
||||
GuacamoleAppHandle,
|
||||
GuacamoleAppInnerProps
|
||||
>(function GuacamoleAppInner(
|
||||
{ hostId, hostConfig, hostName, tabId, protocol, isVisible },
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const { addLog, clearLogs } = useConnectionLog();
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [guacamoleConnectionId, setGuacamoleConnectionId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [sessionLookup, setSessionLookup] = useState<{
|
||||
id: string;
|
||||
origin: ConnectionOrigin;
|
||||
} | null>(null);
|
||||
const [shareModalOpen, setShareModalOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
const [isDisplayReady, setIsDisplayReady] = useState(false);
|
||||
const [touchMode, setTouchMode] = useState<GuacamoleTouchMode | null>(() =>
|
||||
typeof window !== "undefined" &&
|
||||
(navigator.maxTouchPoints > 0 || "ontouchstart" in window)
|
||||
? "touchscreen"
|
||||
: null,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!isDisplayReady || !sessionLookup) return;
|
||||
return watchGuacamoleConnectionId(
|
||||
sessionLookup.id,
|
||||
sessionLookup.origin,
|
||||
setGuacamoleConnectionId,
|
||||
);
|
||||
}, [isDisplayReady, sessionLookup]);
|
||||
const displayRef = useRef<GuacamoleDisplayHandle>(null);
|
||||
const [displayZoom, setDisplayZoom] = useState(1);
|
||||
const [filesystem, setFilesystem] = useState<Guacamole.Object | null>(null);
|
||||
const [fileBrowserOpen, setFileBrowserOpen] = useState(false);
|
||||
const [toolbarHidden, setToolbarHidden] = useState(false);
|
||||
const [pendingUploads, setPendingUploads] = useState<File[]>([]);
|
||||
|
||||
const guacConfig = parseGuacamoleConfig(hostConfig.guacamoleConfig);
|
||||
const allowUpload = guacConfig.disableUpload !== true;
|
||||
const allowDownload = guacConfig.disableDownload !== 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(
|
||||
t(
|
||||
allowUpload
|
||||
? "guacamole.files.driveUnavailable"
|
||||
: "guacamole.files.uploadDisabled",
|
||||
),
|
||||
);
|
||||
}, [allowUpload, t]);
|
||||
|
||||
const resolvedProtocolForConnect = (protocol ??
|
||||
hostConfig.connectionType ??
|
||||
"rdp") as "rdp" | "vnc" | "telnet";
|
||||
const needsCredentialPrompt = needsRdpCredentialPrompt({
|
||||
protocol: resolvedProtocolForConnect,
|
||||
rdpAuthType: hostConfig.rdpAuthType,
|
||||
authOverrides: hostConfig.authOverrides,
|
||||
});
|
||||
|
||||
const [promptedCredentials, setPromptedCredentials] = useState<{
|
||||
username: string;
|
||||
password: string;
|
||||
domain: string;
|
||||
} | null>(null);
|
||||
const [promptOpen, setPromptOpen] = useState(needsCredentialPrompt);
|
||||
const [promptUsername, setPromptUsername] = useState("");
|
||||
const [promptPassword, setPromptPassword] = useState("");
|
||||
const [promptDomain, setPromptDomain] = useState(hostConfig.domain ?? "");
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
disconnect: () => displayRef.current?.disconnect(),
|
||||
isConnected: () => displayRef.current?.isConnected() === true,
|
||||
openShareModal: () => setShareModalOpen(true),
|
||||
canShare: () => guacamoleConnectionId !== null,
|
||||
}));
|
||||
|
||||
const fetchToken = useCallback(async (): Promise<void> => {
|
||||
setToken(null);
|
||||
setIsDisplayReady(false);
|
||||
setSessionLookup(null);
|
||||
setGuacamoleConnectionId(null);
|
||||
setError(null);
|
||||
|
||||
// Outside Electron there is only one backend, so the origin is moot and
|
||||
// the API layer ignores it; inside, it decides which backend mints the
|
||||
// token and therefore has to match the one the session will run on.
|
||||
let resolvedOrigin: ConnectionOrigin = "local";
|
||||
|
||||
if (isElectron()) {
|
||||
resolvedOrigin = await resolveConnectionOrigin({
|
||||
connectionType: resolvedProtocolForConnect,
|
||||
connectionOrigin: hostConfig.connectionOrigin,
|
||||
});
|
||||
if (resolvedOrigin === "remote") {
|
||||
const remoteConfig = (await window.electronAPI?.invoke?.(
|
||||
"get-remote-sync-config",
|
||||
)) as { serverUrl?: string } | null;
|
||||
if (!remoteConfig?.serverUrl) {
|
||||
throw new Error(t("errors.remoteServerRequired"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addLog({
|
||||
type: "info",
|
||||
stage: "guac_guacd",
|
||||
message: t("guacamole.checkingGuacd"),
|
||||
});
|
||||
const status = await getGuacdStatus(resolvedOrigin);
|
||||
if (status.guacd.status !== "connected") {
|
||||
throw new Error(t("guacamole.guacdUnavailable"));
|
||||
}
|
||||
|
||||
addLog({
|
||||
type: "info",
|
||||
stage: "guac_token",
|
||||
message: t("guacamole.requestingToken", {
|
||||
type: resolvedProtocolForConnect.toUpperCase(),
|
||||
}),
|
||||
});
|
||||
// hostId 0 is a quick-connect host: nothing to look up, mint the token
|
||||
// straight from what the user typed. It cannot be shared or logged as
|
||||
// host activity because there is no host row.
|
||||
const result =
|
||||
hostId === 0
|
||||
? await getGuacamoleToken(
|
||||
{
|
||||
protocol: resolvedProtocolForConnect,
|
||||
hostname: hostConfig.ip,
|
||||
port:
|
||||
resolvedProtocolForConnect === "vnc"
|
||||
? hostConfig.vncPort
|
||||
: hostConfig.rdpPort,
|
||||
username:
|
||||
resolvedProtocolForConnect === "vnc"
|
||||
? hostConfig.vncUser
|
||||
: hostConfig.rdpUser,
|
||||
password:
|
||||
resolvedProtocolForConnect === "vnc"
|
||||
? hostConfig.vncPassword
|
||||
: hostConfig.rdpPassword,
|
||||
domain: hostConfig.domain,
|
||||
ignoreCert: true,
|
||||
guacamoleConfig: parseGuacamoleConfig(hostConfig.guacamoleConfig),
|
||||
},
|
||||
resolvedOrigin,
|
||||
)
|
||||
: await getGuacamoleTokenFromHost(
|
||||
hostId,
|
||||
resolvedOrigin,
|
||||
protocol,
|
||||
promptedCredentials ?? undefined,
|
||||
hostConfig.syncId,
|
||||
);
|
||||
if (result) {
|
||||
setSessionLookup(
|
||||
result.termixConnectId
|
||||
? { id: result.termixConnectId, origin: resolvedOrigin }
|
||||
: null,
|
||||
);
|
||||
setToken(result.token);
|
||||
setGuacamoleConnectionId(result.guacamoleConnectionId ?? null);
|
||||
if (hostId !== 0) {
|
||||
logActivity(resolvedProtocolForConnect, hostId, hostName).catch(
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
hostId,
|
||||
hostName,
|
||||
protocol,
|
||||
promptedCredentials,
|
||||
resolvedProtocolForConnect,
|
||||
hostConfig,
|
||||
addLog,
|
||||
t,
|
||||
]);
|
||||
|
||||
const tokenRetry = useConnectionRetry({
|
||||
connect: async () => {
|
||||
try {
|
||||
await fetchToken();
|
||||
tokenRetryRef.current.markConnected();
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err, t("guacamole.failedToConnect"));
|
||||
setError(message || t("guacamole.failedToConnect"));
|
||||
addLog({ type: "error", stage: "error", message });
|
||||
tokenRetryRef.current.markFailed();
|
||||
}
|
||||
},
|
||||
enabled: !needsCredentialPrompt || !!promptedCredentials,
|
||||
autoStart: false,
|
||||
});
|
||||
const tokenRetryRef = useRef(tokenRetry);
|
||||
tokenRetryRef.current = tokenRetry;
|
||||
|
||||
useEffect(() => {
|
||||
if (needsCredentialPrompt && !promptedCredentials) {
|
||||
setPromptOpen(true);
|
||||
return;
|
||||
}
|
||||
clearLogs();
|
||||
tokenRetryRef.current.reset();
|
||||
tokenRetryRef.current.retryNow();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hostId, protocol, needsCredentialPrompt, promptedCredentials]);
|
||||
|
||||
const handleReconnect = useCallback(() => {
|
||||
setConnectionError(null);
|
||||
setError(null);
|
||||
setToken(null);
|
||||
setIsDisplayReady(false);
|
||||
if (needsCredentialPrompt) {
|
||||
setPromptedCredentials(null);
|
||||
setPromptUsername("");
|
||||
setPromptPassword("");
|
||||
setPromptDomain(hostConfig.domain ?? "");
|
||||
setPromptOpen(true);
|
||||
return;
|
||||
}
|
||||
clearLogs();
|
||||
tokenRetryRef.current.reset();
|
||||
tokenRetryRef.current.retryNow();
|
||||
}, [needsCredentialPrompt, hostConfig.domain, clearLogs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tabId) return;
|
||||
const handler = (e: Event) => {
|
||||
const { tabId: eventTabId } = (e as CustomEvent).detail;
|
||||
if (eventTabId === tabId) handleReconnect();
|
||||
};
|
||||
window.addEventListener("termix:refresh-guacamole", handler);
|
||||
return () =>
|
||||
window.removeEventListener("termix:refresh-guacamole", handler);
|
||||
}, [tabId, handleReconnect]);
|
||||
|
||||
if (promptOpen) {
|
||||
return (
|
||||
<Dialog
|
||||
open={promptOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPromptOpen(false);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-bold">
|
||||
{t("guacamole.credentialPromptTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs text-muted-foreground">
|
||||
{t("guacamole.credentialPromptDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="flex flex-col gap-4 mt-1"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setPromptedCredentials({
|
||||
username: promptUsername,
|
||||
password: promptPassword,
|
||||
domain: promptDomain,
|
||||
});
|
||||
setPromptOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold">
|
||||
{t("hosts.guac.username")}
|
||||
</label>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="Administrator"
|
||||
value={promptUsername}
|
||||
onChange={(e) => setPromptUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold">
|
||||
{t("hosts.guac.domain")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder="WORKGROUP"
|
||||
value={promptDomain}
|
||||
onChange={(e) => setPromptDomain(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold">
|
||||
{t("hosts.guac.password")}
|
||||
</label>
|
||||
<PasswordInput
|
||||
className="h-8 text-xs pr-8"
|
||||
placeholder="••••••••"
|
||||
value={promptPassword}
|
||||
onChange={(e) => setPromptPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 mt-2">
|
||||
<Button type="submit" variant="outline">
|
||||
{t("guacamole.connect")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !token) {
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
<ConnectionScreen
|
||||
status={error ? tokenRetry.status : "connecting"}
|
||||
message={t("guacamole.connecting", {
|
||||
type: (
|
||||
protocol ||
|
||||
hostConfig.connectionType ||
|
||||
"remote"
|
||||
).toUpperCase(),
|
||||
})}
|
||||
attempt={tokenRetry.attempt}
|
||||
maxAttempts={tokenRetry.maxAttempts}
|
||||
nextRetryInMs={tokenRetry.nextRetryInMs}
|
||||
onManualRetry={handleReconnect}
|
||||
retryLabel={t("guacamole.retry")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedProtocol = resolvedProtocolForConnect;
|
||||
const configuredDpi = readConfiguredDimension(guacConfig.dpi);
|
||||
const configuredWidth = readConfiguredDimension(guacConfig.width);
|
||||
const configuredHeight = readConfiguredDimension(guacConfig.height);
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
{(!isDisplayReady || connectionError) && (
|
||||
<ConnectionScreen
|
||||
status={connectionError ? "disconnected" : "connecting"}
|
||||
message={t("guacamole.connecting", {
|
||||
type: resolvedProtocol.toUpperCase(),
|
||||
})}
|
||||
onManualRetry={handleReconnect}
|
||||
retryLabel={t("guacamole.reconnect")}
|
||||
className="z-50"
|
||||
/>
|
||||
)}
|
||||
<GuacamoleDisplay
|
||||
key={`${token}-${touchMode}`}
|
||||
ref={displayRef}
|
||||
connectionConfig={{
|
||||
token,
|
||||
protocol: resolvedProtocol,
|
||||
type: resolvedProtocol,
|
||||
connectionOrigin: hostConfig.connectionOrigin,
|
||||
width: configuredWidth,
|
||||
height: configuredHeight,
|
||||
dpi: configuredDpi,
|
||||
}}
|
||||
isVisible={isVisible}
|
||||
touchMode={touchMode}
|
||||
allowUpload={canUploadToRdpDrive(
|
||||
allowUpload,
|
||||
guacConfig.enableDrive === true,
|
||||
filesystem !== null,
|
||||
)}
|
||||
onConnect={() => setIsDisplayReady(true)}
|
||||
onError={(err) => {
|
||||
setConnectionError(err);
|
||||
addLog({ type: "error", stage: "error", message: err });
|
||||
}}
|
||||
onStageChange={(stage) => {
|
||||
const type = resolvedProtocol.toUpperCase();
|
||||
switch (stage) {
|
||||
case "guac_connecting":
|
||||
addLog({
|
||||
type: "info",
|
||||
stage,
|
||||
message: t("guacamole.openingSession", { type }),
|
||||
});
|
||||
break;
|
||||
case "guac_handshake":
|
||||
addLog({
|
||||
type: "info",
|
||||
stage,
|
||||
message: t("guacamole.negotiating", { type }),
|
||||
});
|
||||
break;
|
||||
case "guac_ready":
|
||||
addLog({
|
||||
type: "success",
|
||||
stage,
|
||||
message: t("guacamole.sessionReady", { type }),
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}}
|
||||
onZoomChange={setDisplayZoom}
|
||||
onFilesystem={setFilesystem}
|
||||
onDropFiles={handleDropFiles}
|
||||
onDropUnavailable={handleDropUnavailable}
|
||||
/>
|
||||
{filesystem && fileBrowserOpen && (
|
||||
<GuacamoleFileBrowser
|
||||
filesystem={filesystem}
|
||||
allowUpload={allowUpload}
|
||||
allowDownload={allowDownload}
|
||||
pendingUploads={pendingUploads}
|
||||
onPendingUploadsHandled={() => setPendingUploads([])}
|
||||
onClose={() => setFileBrowserOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{hostConfig.enableTerminalToolbar !== false && !toolbarHidden && (
|
||||
<GuacamoleToolbar
|
||||
displayRef={displayRef}
|
||||
protocol={resolvedProtocol}
|
||||
touchMode={touchMode}
|
||||
hasFilesystem={filesystem !== null}
|
||||
fileBrowserOpen={fileBrowserOpen}
|
||||
onToggleFileBrowser={() => setFileBrowserOpen((open) => !open)}
|
||||
onTouchModeChange={setTouchMode}
|
||||
zoom={displayZoom}
|
||||
onHide={() => setToolbarHidden(true)}
|
||||
/>
|
||||
)}
|
||||
{shareModalOpen && guacamoleConnectionId && (
|
||||
<ShareSessionModal
|
||||
open={shareModalOpen}
|
||||
onClose={() => setShareModalOpen(false)}
|
||||
hostId={hostId}
|
||||
sessionId={guacamoleConnectionId}
|
||||
protocol={resolvedProtocol}
|
||||
tabInstanceId={tabId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default GuacamoleApp;
|
||||
@@ -1,967 +0,0 @@
|
||||
import { getErrorMessage } from "../../lib/error-message.js";
|
||||
import type React from "react";
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useImperativeHandle,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import Guacamole from "guacamole-common-js";
|
||||
import { Upload } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getGuacamoleToken, isElectron } from "@/main-axios.ts";
|
||||
import { getBasePath } from "@/lib/base-path.ts";
|
||||
import { buildGuacamoleWebSocketBaseUrl } from "./guacamole-websocket-url.ts";
|
||||
import {
|
||||
resolveConnectionOrigin,
|
||||
buildOriginWsUrl,
|
||||
type ConnectionOrigin,
|
||||
} from "@/lib/connection-origin.ts";
|
||||
import { isPasteShortcut, pasteTextToRemote } from "./guacamole-clipboard.ts";
|
||||
import { getGuacamoleDisplaySize } from "./guacamole-display-size.ts";
|
||||
import { bindPointerInput } from "./guacamole-pointer.ts";
|
||||
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";
|
||||
|
||||
export type GuacamoleConnectionType = "rdp" | "vnc" | "telnet";
|
||||
|
||||
export interface GuacamoleConnectionConfig {
|
||||
token?: string;
|
||||
protocol?: GuacamoleConnectionType;
|
||||
type?: GuacamoleConnectionType;
|
||||
hostname?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
domain?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
dpi?: number;
|
||||
/**
|
||||
* Which backend serves this session. Supplied by the owning app so the
|
||||
* websocket dials the same backend that minted the token; omitted by
|
||||
* shared/collab views, which follow the default for the connection type.
|
||||
*/
|
||||
connectionOrigin?: ConnectionOrigin | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface GuacamoleDisplayHandle {
|
||||
disconnect: () => void;
|
||||
isConnected: () => boolean;
|
||||
sendKey: (keysym: number, pressed: boolean) => void;
|
||||
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;
|
||||
}
|
||||
|
||||
export type GuacamoleTouchMode = "touchscreen" | "touchpad";
|
||||
|
||||
interface GuacamoleDisplayProps {
|
||||
connectionConfig: GuacamoleConnectionConfig;
|
||||
isVisible: boolean;
|
||||
touchMode?: GuacamoleTouchMode | null;
|
||||
allowUpload?: boolean;
|
||||
onConnect?: () => void;
|
||||
onDisconnect?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
onFilesystem?: (filesystem: Guacamole.Object | null) => void;
|
||||
onDropFiles?: (files: File[]) => void;
|
||||
onDropUnavailable?: () => void;
|
||||
onStageChange?: (stage: ConnectionStage) => void;
|
||||
onZoomChange?: (zoom: number) => void;
|
||||
}
|
||||
|
||||
const isDev = import.meta.env.DEV;
|
||||
|
||||
export const GuacamoleDisplay = forwardRef<
|
||||
GuacamoleDisplayHandle,
|
||||
GuacamoleDisplayProps
|
||||
>(function GuacamoleDisplay(
|
||||
{
|
||||
connectionConfig,
|
||||
isVisible,
|
||||
touchMode,
|
||||
allowUpload = false,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
onError,
|
||||
onFilesystem,
|
||||
onDropFiles,
|
||||
onDropUnavailable,
|
||||
onStageChange,
|
||||
onZoomChange,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
// The host config pins the session resolution; without it the display follows
|
||||
// the container.
|
||||
const hasConfiguredSize =
|
||||
connectionConfig.width != null && connectionConfig.height != null;
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const displayRef = useRef<HTMLDivElement>(null);
|
||||
const displayElementRef = useRef<HTMLElement | null>(null);
|
||||
const clientRef = useRef<Guacamole.Client | null>(null);
|
||||
const filesystemRef = useRef<Guacamole.Object | null>(null);
|
||||
// Held in a ref so tearing down the client can report the loss without
|
||||
// rebuilding the connect callback whenever the parent re-renders.
|
||||
const onFilesystemRef = useRef(onFilesystem);
|
||||
onFilesystemRef.current = onFilesystem;
|
||||
const onStageChangeRef = useRef(onStageChange);
|
||||
onStageChangeRef.current = onStageChange;
|
||||
const keyboardRef = useRef<Guacamole.Keyboard | null>(null);
|
||||
const unbindPointerRef = useRef<(() => void) | null>(null);
|
||||
const scaleRef = useRef<number>(1);
|
||||
const fitScaleRef = useRef<number>(1);
|
||||
const zoomRef = useRef<number>(1);
|
||||
const resizeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const hasKeyboardFocusRef = useRef(false);
|
||||
const windowFocusedRef = useRef(
|
||||
typeof document === "undefined" ? true : document.hasFocus(),
|
||||
);
|
||||
const hasInitiatedRef = useRef(false);
|
||||
const isMountedRef = useRef(false);
|
||||
const isConnectingRef = useRef(false);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
// RDP's guacd backend (FreeRDP) can report the Guacamole-level CONNECTED
|
||||
// state from a single early sync before the actual RDP handshake with the
|
||||
// remote host has finished negotiating - a handshake that can then still
|
||||
// fail up to ~30s later. Waiting for a couple of real frame syncs before
|
||||
// announcing the connection avoids a black screen with no connecting UI
|
||||
// during that window; VNC/telnet fail before ever reaching CONNECTED, so
|
||||
// they are unaffected.
|
||||
const syncCountRef = useRef(0);
|
||||
|
||||
const disconnectClient = useCallback(() => {
|
||||
unbindPointerRef.current?.();
|
||||
unbindPointerRef.current = null;
|
||||
const client = clientRef.current;
|
||||
clientRef.current = null;
|
||||
isConnectingRef.current = false;
|
||||
if (filesystemRef.current) {
|
||||
filesystemRef.current = null;
|
||||
onFilesystemRef.current?.(null);
|
||||
}
|
||||
if (!client) return;
|
||||
|
||||
try {
|
||||
client.disconnect();
|
||||
} catch (error) {
|
||||
console.warn("Failed to disconnect Guacamole client", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const applyZoom = useCallback(
|
||||
(nextZoom: number): number => {
|
||||
const zoom = clampGuacamoleZoom(nextZoom);
|
||||
zoomRef.current = zoom;
|
||||
const display = clientRef.current?.getDisplay();
|
||||
if (display && displayRef.current) {
|
||||
const scale = fitScaleRef.current * zoom;
|
||||
scaleRef.current = scale;
|
||||
display.scale(scale);
|
||||
displayRef.current.style.width = `${display.getWidth() * scale}px`;
|
||||
displayRef.current.style.height = `${display.getHeight() * scale}px`;
|
||||
}
|
||||
onZoomChange?.(zoom);
|
||||
return zoom;
|
||||
},
|
||||
[onZoomChange],
|
||||
);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
disconnect: disconnectClient,
|
||||
isConnected: () => isReady && !hasError,
|
||||
sendKey: (keysym: number, pressed: boolean) => {
|
||||
if (clientRef.current) {
|
||||
clientRef.current.sendKeyEvent(pressed ? 1 : 0, keysym);
|
||||
}
|
||||
},
|
||||
sendMouse: (x: number, y: number, buttonMask: number) => {
|
||||
if (clientRef.current) {
|
||||
clientRef.current.sendMouseState(
|
||||
new Guacamole.Mouse.State({
|
||||
x,
|
||||
y,
|
||||
left: !!(buttonMask & 1),
|
||||
middle: !!(buttonMask & 2),
|
||||
right: !!(buttonMask & 4),
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
setClipboard: (data: string) => {
|
||||
if (clientRef.current) {
|
||||
const stream = clientRef.current.createClipboardStream("text/plain");
|
||||
const writer = new Guacamole.StringWriter(stream);
|
||||
writer.sendText(data);
|
||||
writer.sendEnd();
|
||||
}
|
||||
},
|
||||
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),
|
||||
}));
|
||||
|
||||
const getWebSocketConnection = useCallback(
|
||||
async (
|
||||
containerWidth: number,
|
||||
containerHeight: number,
|
||||
): Promise<{ url: string; query: string } | null> => {
|
||||
try {
|
||||
let token: string;
|
||||
const connectionProtocol =
|
||||
connectionConfig.protocol ?? connectionConfig.type;
|
||||
|
||||
// Resolved before the token is minted, not just before the socket is
|
||||
// opened: the token has to come from whichever backend will serve the
|
||||
// session, so both steps have to agree on the origin.
|
||||
const origin = await resolveConnectionOrigin({
|
||||
connectionType: connectionProtocol,
|
||||
connectionOrigin: connectionConfig.connectionOrigin,
|
||||
});
|
||||
|
||||
if (connectionConfig.token) {
|
||||
token = connectionConfig.token;
|
||||
} else {
|
||||
const data = await getGuacamoleToken(
|
||||
{
|
||||
protocol: connectionProtocol ?? "rdp",
|
||||
hostname: String(connectionConfig.hostname ?? ""),
|
||||
port: connectionConfig.port,
|
||||
username: connectionConfig.username,
|
||||
password: connectionConfig.password,
|
||||
domain: connectionConfig.domain,
|
||||
security:
|
||||
typeof connectionConfig.security === "string"
|
||||
? connectionConfig.security
|
||||
: undefined,
|
||||
ignoreCert:
|
||||
typeof connectionConfig.ignoreCert === "boolean"
|
||||
? connectionConfig.ignoreCert
|
||||
: undefined,
|
||||
guacamoleConfig: connectionConfig.guacamoleConfig as Parameters<
|
||||
typeof getGuacamoleToken
|
||||
>[0]["guacamoleConfig"],
|
||||
},
|
||||
origin,
|
||||
);
|
||||
token = data.token;
|
||||
}
|
||||
|
||||
const displaySize = getGuacamoleDisplaySize(
|
||||
connectionConfig.width ?? containerWidth ?? 1280,
|
||||
connectionConfig.height ?? containerHeight ?? 720,
|
||||
connectionProtocol,
|
||||
window.devicePixelRatio,
|
||||
connectionConfig.dpi,
|
||||
);
|
||||
|
||||
let wsBase: string | null;
|
||||
if (isElectron()) {
|
||||
const target = await buildOriginWsUrl({
|
||||
origin,
|
||||
localPort: 30008,
|
||||
localPath: "/guacamole/websocket/",
|
||||
remotePath: "/guacamole/websocket/",
|
||||
includeJwt: false,
|
||||
});
|
||||
if (!target) {
|
||||
onError?.(t("errors.remoteServerRequired"));
|
||||
return null;
|
||||
}
|
||||
wsBase = target.url;
|
||||
} else {
|
||||
wsBase = buildGuacamoleWebSocketBaseUrl({
|
||||
isDev,
|
||||
isElectronApp: false,
|
||||
isEmbeddedApp: false,
|
||||
basePath: getBasePath(),
|
||||
location: window.location,
|
||||
});
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
token,
|
||||
width: String(displaySize.width),
|
||||
height: String(displaySize.height),
|
||||
});
|
||||
if (displaySize.dpi) params.set("dpi", String(displaySize.dpi));
|
||||
return { url: wsBase, query: params.toString() };
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error);
|
||||
onError?.(errorMessage);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[connectionConfig, onError, t],
|
||||
);
|
||||
|
||||
const refreshKeyboardHandlers = useCallback(() => {
|
||||
const keyboard = keyboardRef.current;
|
||||
const client = clientRef.current;
|
||||
const displayElement = displayElementRef.current;
|
||||
|
||||
if (!keyboard) return;
|
||||
|
||||
const documentVisible =
|
||||
typeof document === "undefined" || document.visibilityState === "visible";
|
||||
const displayIsFocused =
|
||||
!!displayElement &&
|
||||
typeof document !== "undefined" &&
|
||||
document.activeElement === displayElement;
|
||||
const shouldCaptureInput =
|
||||
!!client &&
|
||||
!!displayElement &&
|
||||
isVisible &&
|
||||
documentVisible &&
|
||||
windowFocusedRef.current &&
|
||||
(hasKeyboardFocusRef.current || displayIsFocused);
|
||||
|
||||
if (!shouldCaptureInput) {
|
||||
keyboard.onkeydown = null;
|
||||
keyboard.onkeyup = null;
|
||||
keyboard.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
keyboard.onkeydown = (keysym: number) => {
|
||||
if (!clientRef.current) return;
|
||||
if (!isVisible || !windowFocusedRef.current) return;
|
||||
|
||||
const activeDisplay = displayElementRef.current;
|
||||
const stillFocused =
|
||||
!!activeDisplay &&
|
||||
typeof document !== "undefined" &&
|
||||
document.activeElement === activeDisplay;
|
||||
|
||||
if (!hasKeyboardFocusRef.current && !stillFocused) return;
|
||||
clientRef.current.sendKeyEvent(1, keysym);
|
||||
};
|
||||
|
||||
keyboard.onkeyup = (keysym: number) => {
|
||||
if (!clientRef.current) return;
|
||||
if (!isVisible || !windowFocusedRef.current) return;
|
||||
|
||||
const activeDisplay = displayElementRef.current;
|
||||
const stillFocused =
|
||||
!!activeDisplay &&
|
||||
typeof document !== "undefined" &&
|
||||
document.activeElement === activeDisplay;
|
||||
|
||||
if (!hasKeyboardFocusRef.current && !stillFocused) return;
|
||||
clientRef.current.sendKeyEvent(0, keysym);
|
||||
};
|
||||
}, [isVisible]);
|
||||
|
||||
const rescaleDisplay = useCallback(
|
||||
(immediate: boolean = false) => {
|
||||
if (!clientRef.current || !containerRef.current) return;
|
||||
|
||||
const performRescale = () => {
|
||||
if (!clientRef.current || !containerRef.current) return;
|
||||
|
||||
const display = clientRef.current.getDisplay();
|
||||
const cWidth = containerRef.current.clientWidth;
|
||||
const cHeight = containerRef.current.clientHeight;
|
||||
const displayWidth = display.getWidth();
|
||||
const displayHeight = display.getHeight();
|
||||
|
||||
if (
|
||||
displayWidth > 0 &&
|
||||
displayHeight > 0 &&
|
||||
cWidth > 0 &&
|
||||
cHeight > 0
|
||||
) {
|
||||
fitScaleRef.current = Math.min(
|
||||
cWidth / displayWidth,
|
||||
cHeight / displayHeight,
|
||||
);
|
||||
applyZoom(zoomRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
if (immediate) {
|
||||
performRescale();
|
||||
} else {
|
||||
if (resizeTimeoutRef.current) {
|
||||
clearTimeout(resizeTimeoutRef.current);
|
||||
}
|
||||
resizeTimeoutRef.current = setTimeout(performRescale, 200);
|
||||
}
|
||||
},
|
||||
[applyZoom],
|
||||
);
|
||||
|
||||
const connect = useCallback(async () => {
|
||||
if (isConnectingRef.current) return;
|
||||
isConnectingRef.current = true;
|
||||
setIsReady(false);
|
||||
setHasError(false);
|
||||
syncCountRef.current = 0;
|
||||
|
||||
// Let layout settle before measuring without depending on animation frames,
|
||||
// which may be throttled while Electron windows or tabs are inactive.
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
if (!isMountedRef.current) {
|
||||
isConnectingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// The tab's DOM node can still be display:none (and report 0x0) when this
|
||||
// tab is restored in the background. Measuring then would force the
|
||||
// window-size fallback, which ignores the tab bar and makes the remote
|
||||
// resolution too tall (the bottom gets cut off). Poll briefly for a real
|
||||
// size before connecting so we capture the actual visible viewport.
|
||||
const measureContainer = () => {
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
return { width: rect?.width || 0, height: rect?.height || 0 };
|
||||
};
|
||||
|
||||
let { width: containerWidth, height: containerHeight } = measureContainer();
|
||||
for (
|
||||
let attempt = 0;
|
||||
(containerWidth < 100 || containerHeight < 100) && attempt < 40;
|
||||
attempt++
|
||||
) {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 25));
|
||||
if (!isMountedRef.current) {
|
||||
isConnectingRef.current = false;
|
||||
return;
|
||||
}
|
||||
({ width: containerWidth, height: containerHeight } = measureContainer());
|
||||
}
|
||||
|
||||
if (containerWidth < 100 || containerHeight < 100) {
|
||||
containerWidth = window.innerWidth || 1280;
|
||||
containerHeight = window.innerHeight || 720;
|
||||
}
|
||||
|
||||
const wsConnection = await getWebSocketConnection(
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
);
|
||||
if (!isMountedRef.current) {
|
||||
isConnectingRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!wsConnection) {
|
||||
isConnectingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const tunnel = new Guacamole.WebSocketTunnel(wsConnection.url);
|
||||
const client = new Guacamole.Client(tunnel);
|
||||
clientRef.current = client;
|
||||
|
||||
const display = client.getDisplay();
|
||||
const displayElement = display.getElement();
|
||||
displayElementRef.current = displayElement;
|
||||
|
||||
if (displayRef.current) {
|
||||
displayRef.current.innerHTML = "";
|
||||
displayRef.current.appendChild(displayElement);
|
||||
}
|
||||
|
||||
displayElement.setAttribute("tabindex", "0");
|
||||
displayElement.style.outline = "none";
|
||||
|
||||
// Reading navigator.clipboard outside a user gesture is denied by Safari
|
||||
// and commonly denied by Chromium. The paste event carries the text under
|
||||
// the browser's normal permission model, so use it on every browser and
|
||||
// replace the original shortcut with an ordered clipboard update + Ctrl+V.
|
||||
displayElement.addEventListener(
|
||||
"keydown",
|
||||
(event) => {
|
||||
if (isPasteShortcut(event)) {
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
},
|
||||
true,
|
||||
);
|
||||
displayElement.addEventListener(
|
||||
"paste",
|
||||
(event) => {
|
||||
if (clientRef.current !== client) return;
|
||||
const text = event.clipboardData?.getData("text/plain");
|
||||
if (!text) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
pasteTextToRemote(client, text);
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
display.onresize = () => {
|
||||
if (!isMountedRef.current || clientRef.current !== client) return;
|
||||
rescaleDisplay(true);
|
||||
setIsReady(true);
|
||||
};
|
||||
|
||||
const protocol = connectionConfig.protocol ?? connectionConfig.type;
|
||||
if (protocol === "telnet" && isMountedRef.current) {
|
||||
setIsReady(true);
|
||||
}
|
||||
|
||||
const sendMouseState = (state: Guacamole.Mouse.State) => {
|
||||
displayElement.focus({ preventScroll: true });
|
||||
const scale = scaleRef.current;
|
||||
const adjustedState = new Guacamole.Mouse.State(
|
||||
Math.round(state.x / scale),
|
||||
Math.round(state.y / scale),
|
||||
state.left,
|
||||
state.middle,
|
||||
state.right,
|
||||
state.up,
|
||||
state.down,
|
||||
) as Guacamole.Mouse.State;
|
||||
client.sendMouseState(adjustedState);
|
||||
};
|
||||
|
||||
unbindPointerRef.current?.();
|
||||
unbindPointerRef.current = bindPointerInput(
|
||||
displayElement,
|
||||
touchMode,
|
||||
sendMouseState,
|
||||
);
|
||||
|
||||
const keyboard = new Guacamole.Keyboard(displayElement);
|
||||
keyboardRef.current = keyboard;
|
||||
|
||||
const handleDisplayFocus = () => {
|
||||
hasKeyboardFocusRef.current = true;
|
||||
refreshKeyboardHandlers();
|
||||
};
|
||||
|
||||
const handleDisplayBlur = () => {
|
||||
hasKeyboardFocusRef.current = false;
|
||||
refreshKeyboardHandlers();
|
||||
};
|
||||
|
||||
displayElement.addEventListener("focus", handleDisplayFocus);
|
||||
displayElement.addEventListener("blur", handleDisplayBlur);
|
||||
displayElement.addEventListener("mousedown", handleDisplayFocus);
|
||||
displayElement.addEventListener("touchstart", handleDisplayFocus, {
|
||||
passive: true,
|
||||
});
|
||||
refreshKeyboardHandlers();
|
||||
|
||||
client.onstatechange = (state: number) => {
|
||||
if (!isMountedRef.current || clientRef.current !== client) return;
|
||||
onStageChangeRef.current?.(guacStateToStage(state));
|
||||
switch (state) {
|
||||
case 0:
|
||||
break;
|
||||
case 1:
|
||||
break;
|
||||
case 2:
|
||||
break;
|
||||
case 3:
|
||||
isConnectingRef.current = false;
|
||||
if (protocol !== "rdp") {
|
||||
setIsReady(true);
|
||||
onConnect?.();
|
||||
}
|
||||
// A configured resolution is the size the session should render at;
|
||||
// resizing it to the container would discard it. rescaleDisplay still
|
||||
// fits that fixed display into whatever space is available.
|
||||
if (!hasConfiguredSize && containerRef.current) {
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const size = getGuacamoleDisplaySize(
|
||||
rect.width,
|
||||
rect.height,
|
||||
protocol,
|
||||
window.devicePixelRatio,
|
||||
connectionConfig.dpi,
|
||||
);
|
||||
client.sendSize(size.width, size.height);
|
||||
}
|
||||
rescaleDisplay(false);
|
||||
break;
|
||||
case 4:
|
||||
break;
|
||||
case 5:
|
||||
isConnectingRef.current = false;
|
||||
setIsReady(false);
|
||||
setHasError(true);
|
||||
hasKeyboardFocusRef.current = false;
|
||||
refreshKeyboardHandlers();
|
||||
onError?.(t("guacamole.connectionError"));
|
||||
onDisconnect?.();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
client.onerror = (error: Guacamole.Status) => {
|
||||
if (!isMountedRef.current || clientRef.current !== client) return;
|
||||
const errorMessage = error.message || t("guacamole.connectionError");
|
||||
setIsReady(false);
|
||||
setHasError(true);
|
||||
isConnectingRef.current = false;
|
||||
onError?.(errorMessage);
|
||||
};
|
||||
|
||||
if (protocol === "rdp") {
|
||||
client.onsync = () => {
|
||||
if (!isMountedRef.current || clientRef.current !== client) return;
|
||||
if (syncCountRef.current >= 2) return;
|
||||
syncCountRef.current += 1;
|
||||
if (syncCountRef.current >= 2) {
|
||||
setIsReady(true);
|
||||
onConnect?.();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
client.onclipboard = (stream: Guacamole.InputStream, mimetype: string) => {
|
||||
if (mimetype === "text/plain") {
|
||||
const reader = new Guacamole.StringReader(stream);
|
||||
let data = "";
|
||||
reader.ontext = (text: string) => {
|
||||
data += text;
|
||||
};
|
||||
reader.onend = () => {
|
||||
navigator.clipboard?.writeText?.(data).catch(() => {});
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
client.onaudio = (stream: Guacamole.InputStream, mimetype: string) => {
|
||||
Guacamole.AudioPlayer.getInstance(stream, mimetype);
|
||||
};
|
||||
|
||||
// Only fires when the connection enables drive redirection; guacd exposes
|
||||
// the redirected drive as a single filesystem object.
|
||||
client.onfilesystem = (filesystem: Guacamole.Object) => {
|
||||
if (!isMountedRef.current || clientRef.current !== client) return;
|
||||
filesystemRef.current = filesystem;
|
||||
onFilesystemRef.current?.(filesystem);
|
||||
|
||||
filesystem.onundefine = () => {
|
||||
if (filesystemRef.current !== filesystem) return;
|
||||
filesystemRef.current = null;
|
||||
onFilesystemRef.current?.(null);
|
||||
};
|
||||
};
|
||||
|
||||
client.onfile = (
|
||||
stream: Guacamole.InputStream,
|
||||
mimetype: string,
|
||||
filename: string,
|
||||
) => {
|
||||
const reader = new Guacamole.BlobReader(stream, mimetype);
|
||||
reader.onend = () => {
|
||||
const blob = reader.getBlob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
stream.sendAck("OK", Guacamole.Status.Code.SUCCESS);
|
||||
};
|
||||
|
||||
try {
|
||||
client.connect(wsConnection.query);
|
||||
} catch (error) {
|
||||
isConnectingRef.current = false;
|
||||
if (!isMountedRef.current) return;
|
||||
setIsReady(false);
|
||||
setHasError(true);
|
||||
onError?.(getErrorMessage(error, t("guacamole.connectionError")));
|
||||
}
|
||||
}, [
|
||||
getWebSocketConnection,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
onError,
|
||||
refreshKeyboardHandlers,
|
||||
rescaleDisplay,
|
||||
connectionConfig.protocol,
|
||||
connectionConfig.type,
|
||||
connectionConfig.dpi,
|
||||
hasConfiguredSize,
|
||||
touchMode,
|
||||
t,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
|
||||
if (isVisible && !hasInitiatedRef.current) {
|
||||
hasInitiatedRef.current = true;
|
||||
connect();
|
||||
}
|
||||
}, [isVisible, connect]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
hasKeyboardFocusRef.current = false;
|
||||
}
|
||||
|
||||
refreshKeyboardHandlers();
|
||||
}, [isVisible, refreshKeyboardHandlers]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleWindowFocus = () => {
|
||||
windowFocusedRef.current = true;
|
||||
refreshKeyboardHandlers();
|
||||
};
|
||||
|
||||
const handleWindowBlur = () => {
|
||||
windowFocusedRef.current = false;
|
||||
hasKeyboardFocusRef.current = false;
|
||||
refreshKeyboardHandlers();
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
windowFocusedRef.current =
|
||||
document.visibilityState === "visible" && document.hasFocus();
|
||||
if (document.visibilityState !== "visible") {
|
||||
hasKeyboardFocusRef.current = false;
|
||||
}
|
||||
refreshKeyboardHandlers();
|
||||
};
|
||||
|
||||
window.addEventListener("focus", handleWindowFocus);
|
||||
window.addEventListener("blur", handleWindowBlur);
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("focus", handleWindowFocus);
|
||||
window.removeEventListener("blur", handleWindowBlur);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [refreshKeyboardHandlers]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
hasInitiatedRef.current = false;
|
||||
isConnectingRef.current = false;
|
||||
if (resizeTimeoutRef.current) {
|
||||
clearTimeout(resizeTimeoutRef.current);
|
||||
}
|
||||
disconnectClient();
|
||||
displayElementRef.current = null;
|
||||
};
|
||||
}, [disconnectClient]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (resizeTimeoutRef.current) clearTimeout(resizeTimeoutRef.current);
|
||||
resizeTimeoutRef.current = setTimeout(() => {
|
||||
if (clientRef.current && containerRef.current) {
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
if (rect.width > 0 && rect.height > 0) {
|
||||
if (!hasConfiguredSize) {
|
||||
const size = getGuacamoleDisplaySize(
|
||||
rect.width,
|
||||
rect.height,
|
||||
connectionConfig.protocol ?? connectionConfig.type,
|
||||
window.devicePixelRatio,
|
||||
connectionConfig.dpi,
|
||||
);
|
||||
clientRef.current.sendSize(size.width, size.height);
|
||||
}
|
||||
rescaleDisplay(true);
|
||||
}
|
||||
}
|
||||
}, 150);
|
||||
});
|
||||
|
||||
resizeObserver.observe(containerRef.current);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [
|
||||
connectionConfig.dpi,
|
||||
connectionConfig.protocol,
|
||||
connectionConfig.type,
|
||||
hasConfiguredSize,
|
||||
rescaleDisplay,
|
||||
]);
|
||||
|
||||
const syncClipboard = useCallback(() => {
|
||||
const client = clientRef.current;
|
||||
if (!client || !navigator.clipboard?.readText) return;
|
||||
navigator.clipboard
|
||||
.readText()
|
||||
.then((text) => {
|
||||
if (text) {
|
||||
const stream = client.createClipboardStream("text/plain");
|
||||
const writer = new Guacamole.StringWriter(stream);
|
||||
writer.sendText(text);
|
||||
writer.sendEnd();
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible && isReady) {
|
||||
syncClipboard();
|
||||
}
|
||||
}, [isVisible, isReady, syncClipboard]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const protocol = connectionConfig.protocol ?? connectionConfig.type;
|
||||
if (!container || !isReady || protocol !== "vnc") return;
|
||||
|
||||
let pinchDistance = 0;
|
||||
let pinchZoom = zoomRef.current;
|
||||
const distance = (touches: TouchList) =>
|
||||
Math.hypot(
|
||||
touches[0].clientX - touches[1].clientX,
|
||||
touches[0].clientY - touches[1].clientY,
|
||||
);
|
||||
const onTouchStart = (event: TouchEvent) => {
|
||||
if (event.touches.length !== 2) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
pinchDistance = distance(event.touches);
|
||||
pinchZoom = zoomRef.current;
|
||||
};
|
||||
const onTouchMove = (event: TouchEvent) => {
|
||||
if (event.touches.length !== 2 || pinchDistance === 0) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
applyZoom(pinchZoom * (distance(event.touches) / pinchDistance));
|
||||
};
|
||||
const onTouchEnd = () => {
|
||||
pinchDistance = 0;
|
||||
};
|
||||
|
||||
container.addEventListener("touchstart", onTouchStart, {
|
||||
passive: false,
|
||||
capture: true,
|
||||
});
|
||||
container.addEventListener("touchmove", onTouchMove, {
|
||||
passive: false,
|
||||
capture: true,
|
||||
});
|
||||
container.addEventListener("touchend", onTouchEnd, { capture: true });
|
||||
return () => {
|
||||
container.removeEventListener("touchstart", onTouchStart, true);
|
||||
container.removeEventListener("touchmove", onTouchMove, true);
|
||||
container.removeEventListener("touchend", onTouchEnd, true);
|
||||
};
|
||||
}, [applyZoom, connectionConfig.protocol, connectionConfig.type, isReady]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container || !isReady) return;
|
||||
|
||||
const handleFocus = () => syncClipboard();
|
||||
container.addEventListener("mouseenter", handleFocus);
|
||||
|
||||
return () => {
|
||||
container.removeEventListener("mouseenter", handleFocus);
|
||||
};
|
||||
}, [isReady, syncClipboard]);
|
||||
|
||||
const canDropFiles = allowUpload && onDropFiles != null;
|
||||
const [isDraggingFiles, setIsDraggingFiles] = useState(false);
|
||||
// Nested elements fire dragleave as the pointer crosses them, so track depth
|
||||
// rather than clearing the highlight on the first leave.
|
||||
const dragDepthRef = useRef(0);
|
||||
|
||||
const handleDragEnter = useCallback(
|
||||
(event: React.DragEvent) => {
|
||||
if (!hasDraggedFiles(event.dataTransfer.types)) return;
|
||||
event.preventDefault();
|
||||
if (!canDropFiles) return;
|
||||
dragDepthRef.current += 1;
|
||||
setIsDraggingFiles(true);
|
||||
},
|
||||
[canDropFiles],
|
||||
);
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
||||
if (dragDepthRef.current === 0) setIsDraggingFiles(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(event: React.DragEvent) => {
|
||||
if (!hasDraggedFiles(event.dataTransfer.types)) return;
|
||||
event.preventDefault();
|
||||
dragDepthRef.current = 0;
|
||||
setIsDraggingFiles(false);
|
||||
|
||||
const files = Array.from(event.dataTransfer.files);
|
||||
const disposition = getFileDropDisposition(
|
||||
event.dataTransfer.types,
|
||||
files.length,
|
||||
canDropFiles,
|
||||
);
|
||||
if (disposition === "upload") onDropFiles?.(files);
|
||||
if (disposition === "reject") onDropUnavailable?.();
|
||||
},
|
||||
[canDropFiles, onDropFiles, onDropUnavailable],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute inset-0 overflow-auto"
|
||||
style={{ backgroundColor: "var(--bg-base)" }}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={(event) => {
|
||||
if (hasDraggedFiles(event.dataTransfer.types)) event.preventDefault();
|
||||
}}
|
||||
onDragLeave={canDropFiles ? handleDragLeave : undefined}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div
|
||||
ref={displayRef}
|
||||
className="relative flex min-h-full min-w-full items-center justify-center"
|
||||
style={{
|
||||
cursor: isReady ? "none" : "default",
|
||||
visibility: isReady ? "visible" : "hidden",
|
||||
}}
|
||||
/>
|
||||
|
||||
{isDraggingFiles && (
|
||||
<div className="absolute inset-0 z-30 flex items-center justify-center pointer-events-none bg-background/70 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-2 rounded-sm border-2 border-dashed border-border px-4 py-3 text-sm font-semibold">
|
||||
<Upload className="size-4" />
|
||||
{t("guacamole.files.dropToUpload")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,245 +0,0 @@
|
||||
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";
|
||||
import {
|
||||
ArrowUp,
|
||||
Download,
|
||||
File as FileIcon,
|
||||
Folder,
|
||||
RefreshCw,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import {
|
||||
downloadFile,
|
||||
listDirectory,
|
||||
parentPath,
|
||||
saveBlobAs,
|
||||
uploadFile,
|
||||
describeUploadError,
|
||||
type RemoteFileEntry,
|
||||
} from "./guacamole-filesystem.ts";
|
||||
|
||||
interface GuacamoleFileBrowserProps {
|
||||
filesystem: Guacamole.Object;
|
||||
allowUpload: boolean;
|
||||
allowDownload: boolean;
|
||||
pendingUploads: File[];
|
||||
onPendingUploadsHandled: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function GuacamoleFileBrowser({
|
||||
filesystem,
|
||||
allowUpload,
|
||||
allowDownload,
|
||||
pendingUploads,
|
||||
onPendingUploadsHandled,
|
||||
onClose,
|
||||
}: GuacamoleFileBrowserProps) {
|
||||
const { t } = useTranslation();
|
||||
const [path, setPath] = useState("/");
|
||||
const [entries, setEntries] = useState<RemoteFileEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busyName, setBusyName] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const refresh = useCallback(
|
||||
async (target: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setEntries(await listDirectory(filesystem, target));
|
||||
} catch (err) {
|
||||
setEntries([]);
|
||||
setError(getErrorMessage(err, t("guacamole.files.listFailed")));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[filesystem, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh(path);
|
||||
}, [refresh, path]);
|
||||
|
||||
const upload = useCallback(
|
||||
async (files: File[]) => {
|
||||
for (const file of files) {
|
||||
setBusyName(file.name);
|
||||
try {
|
||||
await uploadFile(filesystem, path, file);
|
||||
toast.success(t("guacamole.files.uploaded", { name: file.name }));
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
describeUploadError(err, (key) =>
|
||||
t(`guacamole.files.${key}`, { name: file.name }),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setBusyName(null);
|
||||
}
|
||||
}
|
||||
void refresh(path);
|
||||
},
|
||||
[filesystem, path, refresh, t],
|
||||
);
|
||||
|
||||
// Files dropped on the display land here so they share the browser's current
|
||||
// directory rather than always going to the drive root.
|
||||
useEffect(() => {
|
||||
if (pendingUploads.length === 0) return;
|
||||
onPendingUploadsHandled();
|
||||
if (!allowUpload) {
|
||||
toast.error(t("guacamole.files.uploadDisabled"));
|
||||
return;
|
||||
}
|
||||
void upload(pendingUploads);
|
||||
}, [pendingUploads, onPendingUploadsHandled, allowUpload, upload, t]);
|
||||
|
||||
const download = useCallback(
|
||||
async (entry: RemoteFileEntry) => {
|
||||
setBusyName(entry.name);
|
||||
try {
|
||||
const { blob, filename } = await downloadFile(filesystem, entry.path);
|
||||
saveBlobAs(blob, filename);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t("guacamole.files.downloadFailed", { name: entry.name }),
|
||||
);
|
||||
} finally {
|
||||
setBusyName(null);
|
||||
}
|
||||
},
|
||||
[filesystem, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="absolute right-2 top-2 bottom-2 z-30 flex w-80 flex-col rounded-sm border border-border bg-background/95 shadow-lg backdrop-blur-sm">
|
||||
<div className="flex items-center gap-1 border-b border-border px-2 py-1.5">
|
||||
<span className="flex-1 truncate text-xs font-semibold">
|
||||
{t("guacamole.files.title")}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6"
|
||||
onClick={onClose}
|
||||
aria-label={t("common.close")}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 border-b border-border px-2 py-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6"
|
||||
disabled={path === "/"}
|
||||
onClick={() => setPath(parentPath(path))}
|
||||
aria-label={t("guacamole.files.parent")}
|
||||
>
|
||||
<ArrowUp className="size-3.5" />
|
||||
</Button>
|
||||
<span className="flex-1 truncate text-[11px] text-muted-foreground">
|
||||
{path}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6"
|
||||
onClick={() => void refresh(path)}
|
||||
aria-label={t("guacamole.files.refresh")}
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading && (
|
||||
<p className="px-2 py-3 text-[11px] text-muted-foreground">
|
||||
{t("common.loading")}
|
||||
</p>
|
||||
)}
|
||||
{!loading && error && (
|
||||
<p className="px-2 py-3 text-[11px] text-red-500">{error}</p>
|
||||
)}
|
||||
{!loading && !error && entries.length === 0 && (
|
||||
<p className="px-2 py-3 text-[11px] text-muted-foreground">
|
||||
{t("guacamole.files.empty")}
|
||||
</p>
|
||||
)}
|
||||
{!loading &&
|
||||
!error &&
|
||||
entries.map((entry) => (
|
||||
<div
|
||||
key={entry.path}
|
||||
className="flex items-center gap-1.5 px-2 py-1 hover:bg-muted"
|
||||
>
|
||||
{entry.isDirectory ? (
|
||||
<Folder className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<FileIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 truncate text-left text-[11px] disabled:opacity-60"
|
||||
disabled={!entry.isDirectory}
|
||||
onClick={() => entry.isDirectory && setPath(entry.path)}
|
||||
>
|
||||
{entry.name}
|
||||
</button>
|
||||
{!entry.isDirectory && allowDownload && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-5 shrink-0"
|
||||
disabled={busyName === entry.name}
|
||||
onClick={() => void download(entry)}
|
||||
aria-label={t("guacamole.files.download")}
|
||||
>
|
||||
<Download className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{allowUpload && (
|
||||
<div className="border-t border-border px-2 py-1.5">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const files = Array.from(e.target.files ?? []);
|
||||
e.target.value = "";
|
||||
if (files.length > 0) void upload(files);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-full text-[11px]"
|
||||
disabled={busyName !== null}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="mr-1 size-3" />
|
||||
{busyName !== null
|
||||
? t("guacamole.files.uploading", { name: busyName })
|
||||
: t("guacamole.files.upload")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,598 +0,0 @@
|
||||
import React, {
|
||||
useState,
|
||||
useRef,
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import {
|
||||
GripVertical,
|
||||
Monitor,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
ChevronsLeftRight,
|
||||
FolderOpen,
|
||||
Touchpad,
|
||||
MousePointer,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Scan,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/tooltip.tsx";
|
||||
import type {
|
||||
GuacamoleDisplayHandle,
|
||||
GuacamoleTouchMode,
|
||||
} from "@/features/guacamole/GuacamoleDisplay.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
detectRuntimeMetaKeyFamily,
|
||||
metaKeyLabels,
|
||||
type MetaKeyFamily,
|
||||
} from "@/features/guacamole/guacamole-meta-key.ts";
|
||||
|
||||
interface GuacamoleToolbarProps {
|
||||
displayRef: React.RefObject<GuacamoleDisplayHandle>;
|
||||
protocol: "rdp" | "vnc" | "telnet";
|
||||
touchMode?: GuacamoleTouchMode | null;
|
||||
hasFilesystem?: boolean;
|
||||
fileBrowserOpen?: boolean;
|
||||
onToggleFileBrowser?: () => void;
|
||||
onTouchModeChange?: (mode: GuacamoleTouchMode) => void;
|
||||
zoom?: number;
|
||||
onHide?: () => void;
|
||||
metaKeyFamily?: MetaKeyFamily;
|
||||
}
|
||||
|
||||
const MODIFIER_KEYSYMS = {
|
||||
ctrl: 0xffe3,
|
||||
alt: 0xffe9,
|
||||
shift: 0xffe1,
|
||||
win: 0xffeb,
|
||||
} as const;
|
||||
|
||||
const FKEY_KEYSYMS = Array.from({ length: 12 }, (_, i) => 0xffbe + i);
|
||||
|
||||
const BTN_BASE =
|
||||
"flex items-center justify-center gap-1 h-7 px-2 text-[10px] font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors rounded-sm whitespace-nowrap select-none";
|
||||
|
||||
const BTN_ICON =
|
||||
"flex items-center justify-center size-7 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors rounded-sm select-none";
|
||||
|
||||
const SEP = "w-px h-5 bg-border mx-0.5 shrink-0";
|
||||
|
||||
function TipBtn({
|
||||
tooltip,
|
||||
onClick,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
tooltip: string;
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={tooltip}
|
||||
onClick={onClick}
|
||||
className={cn(BTN_BASE, className)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function TipIconBtn({
|
||||
tooltip,
|
||||
onClick,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
tooltip: string;
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={tooltip}
|
||||
onClick={onClick}
|
||||
className={cn(BTN_ICON, className)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
|
||||
displayRef,
|
||||
protocol,
|
||||
touchMode,
|
||||
hasFilesystem = false,
|
||||
fileBrowserOpen = false,
|
||||
onToggleFileBrowser,
|
||||
onTouchModeChange,
|
||||
zoom = 1,
|
||||
onHide,
|
||||
metaKeyFamily,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const meta = metaKeyLabels(
|
||||
metaKeyFamily ?? detectRuntimeMetaKeyFamily(protocol),
|
||||
);
|
||||
const [position, setPosition] = useState({ x: 0, y: 12 });
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [showFKeys, setShowFKeys] = useState(false);
|
||||
const [stickyKeys, setStickyKeys] = useState<Record<number, boolean>>({
|
||||
[MODIFIER_KEYSYMS.ctrl]: false,
|
||||
[MODIFIER_KEYSYMS.alt]: false,
|
||||
[MODIFIER_KEYSYMS.shift]: false,
|
||||
[MODIFIER_KEYSYMS.win]: false,
|
||||
});
|
||||
|
||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||
const isDraggingRef = useRef(false);
|
||||
const dragOriginRef = useRef({ mouseX: 0, mouseY: 0, posX: 0, posY: 0 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = toolbarRef.current;
|
||||
if (!el) return;
|
||||
const parent = el.offsetParent as HTMLElement | null;
|
||||
if (!parent) return;
|
||||
const parentW = parent.clientWidth;
|
||||
const toolbarW = el.offsetWidth;
|
||||
setPosition((p) => ({ ...p, x: Math.max(0, (parentW - toolbarW) / 2) }));
|
||||
}, [collapsed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const onMove = (e: MouseEvent) => {
|
||||
if (!isDraggingRef.current) return;
|
||||
const parent = toolbarRef.current?.offsetParent as HTMLElement | null;
|
||||
const parentW = parent?.clientWidth ?? Infinity;
|
||||
const parentH = parent?.clientHeight ?? Infinity;
|
||||
const toolbarW = toolbarRef.current?.offsetWidth ?? 0;
|
||||
const toolbarH = toolbarRef.current?.offsetHeight ?? 0;
|
||||
|
||||
const dx = e.clientX - dragOriginRef.current.mouseX;
|
||||
const dy = e.clientY - dragOriginRef.current.mouseY;
|
||||
setPosition({
|
||||
x: Math.max(
|
||||
0,
|
||||
Math.min(dragOriginRef.current.posX + dx, parentW - toolbarW),
|
||||
),
|
||||
y: Math.max(
|
||||
0,
|
||||
Math.min(dragOriginRef.current.posY + dy, parentH - toolbarH),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
isDraggingRef.current = false;
|
||||
setIsDragging(false);
|
||||
document.body.style.userSelect = "";
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
const startDrag = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
isDraggingRef.current = true;
|
||||
dragOriginRef.current = {
|
||||
mouseX: e.clientX,
|
||||
mouseY: e.clientY,
|
||||
posX: position.x,
|
||||
posY: position.y,
|
||||
};
|
||||
document.body.style.userSelect = "none";
|
||||
setIsDragging(true);
|
||||
},
|
||||
[position],
|
||||
);
|
||||
|
||||
const releaseStickyKeys = useCallback(() => {
|
||||
const display = displayRef.current;
|
||||
if (!display) return;
|
||||
setStickyKeys((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const [ksStr, active] of Object.entries(prev)) {
|
||||
if (active) {
|
||||
display.sendKey(Number(ksStr), false);
|
||||
next[Number(ksStr)] = false;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [displayRef]);
|
||||
|
||||
const sendCombo = useCallback(
|
||||
(...keysyms: number[]) => {
|
||||
const display = displayRef.current;
|
||||
if (!display) return;
|
||||
for (const k of keysyms) display.sendKey(k, true);
|
||||
for (const k of [...keysyms].reverse()) display.sendKey(k, false);
|
||||
releaseStickyKeys();
|
||||
},
|
||||
[displayRef, releaseStickyKeys],
|
||||
);
|
||||
|
||||
const toggleStickyKey = useCallback(
|
||||
(keysym: number) => {
|
||||
const display = displayRef.current;
|
||||
if (!display) return;
|
||||
setStickyKeys((prev) => {
|
||||
const isActive = prev[keysym];
|
||||
display.sendKey(keysym, !isActive);
|
||||
return { ...prev, [keysym]: !isActive };
|
||||
});
|
||||
},
|
||||
[displayRef],
|
||||
);
|
||||
|
||||
const isRdpVnc = protocol === "rdp" || protocol === "vnc";
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
zIndex: 20,
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={500}>
|
||||
<div
|
||||
ref={toolbarRef}
|
||||
style={containerStyle}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
{collapsed ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center bg-background/85 backdrop-blur-sm border border-border shadow-lg rounded-sm overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={startDrag}
|
||||
className="flex items-center justify-center size-7 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors cursor-grab active:cursor-grabbing"
|
||||
>
|
||||
<GripVertical className="size-3" />
|
||||
</button>
|
||||
<div className="w-px h-4 bg-border" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed(false)}
|
||||
className="flex items-center justify-center size-7 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<Monitor className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{t("guacamole.toolbar.expand")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<div className="flex items-center bg-background/85 backdrop-blur-sm border border-border shadow-lg rounded-sm px-0.5 py-0.5 gap-0">
|
||||
{/* Drag handle */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={startDrag}
|
||||
className="flex items-center justify-center h-7 px-1 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors rounded-sm cursor-grab active:cursor-grabbing"
|
||||
>
|
||||
<GripVertical className="size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{t("guacamole.toolbar.dragHandle")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Touch mode toggle — touch devices only */}
|
||||
{touchMode != null && onTouchModeChange && (
|
||||
<>
|
||||
<div className={SEP} />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onTouchModeChange(
|
||||
touchMode === "touchscreen"
|
||||
? "touchpad"
|
||||
: "touchscreen",
|
||||
)
|
||||
}
|
||||
className={cn(BTN_ICON)}
|
||||
>
|
||||
{touchMode === "touchscreen" ? (
|
||||
<MousePointer className="size-3.5" />
|
||||
) : (
|
||||
<Touchpad className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{touchMode === "touchscreen"
|
||||
? t("guacamole.toolbar.switchToTrackpad")
|
||||
: t("guacamole.toolbar.switchToTouch")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Drive files — only once guacd reports a redirected filesystem */}
|
||||
{hasFilesystem && onToggleFileBrowser && (
|
||||
<>
|
||||
<div className={SEP} />
|
||||
<TipIconBtn
|
||||
tooltip={t("guacamole.files.title")}
|
||||
onClick={onToggleFileBrowser}
|
||||
className={cn(fileBrowserOpen && "bg-muted text-foreground")}
|
||||
>
|
||||
<FolderOpen className="size-3.5" />
|
||||
</TipIconBtn>
|
||||
</>
|
||||
)}
|
||||
|
||||
{protocol === "vnc" && (
|
||||
<>
|
||||
<div className={SEP} />
|
||||
<TipIconBtn
|
||||
tooltip={t("guacamole.toolbar.zoomOut")}
|
||||
onClick={() => displayRef.current?.zoomOut()}
|
||||
>
|
||||
<ZoomOut className="size-3.5" />
|
||||
</TipIconBtn>
|
||||
<TipBtn
|
||||
tooltip={t("guacamole.toolbar.resetZoom")}
|
||||
onClick={() => displayRef.current?.resetZoom()}
|
||||
className="min-w-12 tabular-nums"
|
||||
>
|
||||
{Math.round(zoom * 100)}%
|
||||
</TipBtn>
|
||||
<TipIconBtn
|
||||
tooltip={t("guacamole.toolbar.zoomIn")}
|
||||
onClick={() => displayRef.current?.zoomIn()}
|
||||
>
|
||||
<ZoomIn className="size-3.5" />
|
||||
</TipIconBtn>
|
||||
<TipIconBtn
|
||||
tooltip={t("guacamole.toolbar.fitToScreen")}
|
||||
onClick={() => displayRef.current?.resetZoom()}
|
||||
>
|
||||
<Scan className="size-3.5" />
|
||||
</TipIconBtn>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* System combos — RDP/VNC only */}
|
||||
{isRdpVnc && (
|
||||
<>
|
||||
<div className={SEP} />
|
||||
<TipBtn
|
||||
tooltip={t("guacamole.toolbar.ctrlAltDel")}
|
||||
onClick={() => sendCombo(0xffe3, 0xffe9, 0xffff)}
|
||||
>
|
||||
CAD
|
||||
</TipBtn>
|
||||
<TipBtn
|
||||
tooltip={t(meta.lockTooltip)}
|
||||
onClick={() => sendCombo(MODIFIER_KEYSYMS.win, 0x006c)}
|
||||
>
|
||||
{meta.lock}
|
||||
</TipBtn>
|
||||
<TipBtn
|
||||
tooltip={t(meta.keyTooltip)}
|
||||
onClick={() => sendCombo(MODIFIER_KEYSYMS.win)}
|
||||
>
|
||||
{meta.short}
|
||||
</TipBtn>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Sticky modifiers — RDP/VNC only */}
|
||||
{isRdpVnc && (
|
||||
<>
|
||||
<div className={SEP} />
|
||||
{(
|
||||
[
|
||||
[
|
||||
"ctrl",
|
||||
MODIFIER_KEYSYMS.ctrl,
|
||||
t("guacamole.toolbar.ctrl"),
|
||||
],
|
||||
["alt", MODIFIER_KEYSYMS.alt, t("guacamole.toolbar.alt")],
|
||||
[
|
||||
"shift",
|
||||
MODIFIER_KEYSYMS.shift,
|
||||
t("guacamole.toolbar.shift"),
|
||||
],
|
||||
["win", MODIFIER_KEYSYMS.win, t(meta.stickyKey)],
|
||||
] as [string, number, string][]
|
||||
).map(([key, ks, label]) => (
|
||||
<Tooltip key={key}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleStickyKey(ks)}
|
||||
className={cn(
|
||||
BTN_BASE,
|
||||
stickyKeys[ks] &&
|
||||
"bg-primary/15 text-primary border border-primary/30",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{stickyKeys[ks]
|
||||
? t("guacamole.toolbar.stickyActive", { key: label })
|
||||
: t("guacamole.toolbar.stickyInactive", { key: label })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Function key toggle */}
|
||||
<div className={SEP} />
|
||||
<TipBtn
|
||||
tooltip={t("guacamole.toolbar.fnToggle")}
|
||||
onClick={() => setShowFKeys((v) => !v)}
|
||||
className={cn(
|
||||
showFKeys &&
|
||||
"bg-primary/15 text-primary border border-primary/30",
|
||||
)}
|
||||
>
|
||||
Fn
|
||||
</TipBtn>
|
||||
|
||||
{/* F1-F12 row */}
|
||||
{showFKeys &&
|
||||
FKEY_KEYSYMS.map((ks, i) => (
|
||||
<TipBtn
|
||||
key={ks}
|
||||
tooltip={`F${i + 1}`}
|
||||
onClick={() => sendCombo(ks)}
|
||||
>
|
||||
F{i + 1}
|
||||
</TipBtn>
|
||||
))}
|
||||
|
||||
{/* Navigation */}
|
||||
<div className={SEP} />
|
||||
<TipBtn
|
||||
tooltip={t("guacamole.toolbar.esc")}
|
||||
onClick={() => sendCombo(0xff1b)}
|
||||
>
|
||||
Esc
|
||||
</TipBtn>
|
||||
<TipBtn
|
||||
tooltip={t("guacamole.toolbar.tab")}
|
||||
onClick={() => sendCombo(0xff09)}
|
||||
>
|
||||
Tab
|
||||
</TipBtn>
|
||||
<TipBtn
|
||||
tooltip={t("guacamole.toolbar.home")}
|
||||
onClick={() => sendCombo(0xff50)}
|
||||
>
|
||||
Home
|
||||
</TipBtn>
|
||||
<TipBtn
|
||||
tooltip={t("guacamole.toolbar.end")}
|
||||
onClick={() => sendCombo(0xff57)}
|
||||
>
|
||||
End
|
||||
</TipBtn>
|
||||
<TipBtn
|
||||
tooltip={t("guacamole.toolbar.pageUp")}
|
||||
onClick={() => sendCombo(0xff55)}
|
||||
>
|
||||
PgUp
|
||||
</TipBtn>
|
||||
<TipBtn
|
||||
tooltip={t("guacamole.toolbar.pageDown")}
|
||||
onClick={() => sendCombo(0xff56)}
|
||||
>
|
||||
PgDn
|
||||
</TipBtn>
|
||||
|
||||
{/* Arrow cluster */}
|
||||
<div className="flex flex-col ml-0.5">
|
||||
<div className="flex justify-center">
|
||||
<TipIconBtn
|
||||
tooltip={t("guacamole.toolbar.arrowUp")}
|
||||
onClick={() => sendCombo(0xff52)}
|
||||
>
|
||||
<ChevronUp className="size-3" />
|
||||
</TipIconBtn>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<TipIconBtn
|
||||
tooltip={t("guacamole.toolbar.arrowLeft")}
|
||||
onClick={() => sendCombo(0xff51)}
|
||||
>
|
||||
<ChevronLeft className="size-3" />
|
||||
</TipIconBtn>
|
||||
<TipIconBtn
|
||||
tooltip={t("guacamole.toolbar.arrowDown")}
|
||||
onClick={() => sendCombo(0xff54)}
|
||||
>
|
||||
<ChevronDown className="size-3" />
|
||||
</TipIconBtn>
|
||||
<TipIconBtn
|
||||
tooltip={t("guacamole.toolbar.arrowRight")}
|
||||
onClick={() => sendCombo(0xff53)}
|
||||
>
|
||||
<ChevronRight className="size-3" />
|
||||
</TipIconBtn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Collapse */}
|
||||
<div className="w-px h-5 bg-border mx-0.5 shrink-0" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed(true)}
|
||||
className={cn(BTN_ICON)}
|
||||
>
|
||||
<ChevronsLeftRight className="size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{t("guacamole.toolbar.collapse")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{onHide && (
|
||||
<TipIconBtn
|
||||
tooltip={t("guacamole.toolbar.hide")}
|
||||
onClick={onHide}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</TipIconBtn>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
@@ -1,39 +0,0 @@
|
||||
import Guacamole from "guacamole-common-js";
|
||||
|
||||
const CONTROL_LEFT_KEYSYM = 0xffe3;
|
||||
const V_KEYSYM = 0x76;
|
||||
|
||||
interface ClipboardOutputStream {
|
||||
sendBlob(data: string): void;
|
||||
sendEnd(): void;
|
||||
}
|
||||
|
||||
export interface GuacamoleClipboardClient {
|
||||
createClipboardStream(mimetype: string): ClipboardOutputStream;
|
||||
sendKeyEvent(pressed: number, keysym: number): void;
|
||||
}
|
||||
|
||||
export function isPasteShortcut(
|
||||
event: Pick<KeyboardEvent, "altKey" | "ctrlKey" | "key" | "metaKey">,
|
||||
): boolean {
|
||||
return (
|
||||
!event.altKey &&
|
||||
(event.ctrlKey || event.metaKey) &&
|
||||
event.key.toLowerCase() === "v"
|
||||
);
|
||||
}
|
||||
|
||||
export function pasteTextToRemote(
|
||||
client: GuacamoleClipboardClient,
|
||||
text: string,
|
||||
): void {
|
||||
const stream = client.createClipboardStream("text/plain");
|
||||
const writer = new Guacamole.StringWriter(stream);
|
||||
writer.sendText(text);
|
||||
writer.sendEnd();
|
||||
|
||||
client.sendKeyEvent(1, CONTROL_LEFT_KEYSYM);
|
||||
client.sendKeyEvent(1, V_KEYSYM);
|
||||
client.sendKeyEvent(0, V_KEYSYM);
|
||||
client.sendKeyEvent(0, CONTROL_LEFT_KEYSYM);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
const DEFAULT_RDP_DPI = 96;
|
||||
const MAX_DEVICE_PIXEL_RATIO = 3;
|
||||
|
||||
/**
|
||||
* Reads a guacamoleConfig display field. The UI stores these as strings, and
|
||||
* leaves them empty when the size should follow the browser window.
|
||||
*/
|
||||
export function readConfiguredDimension(value: unknown): number | undefined {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
export interface GuacamoleDisplaySize {
|
||||
width: number;
|
||||
height: number;
|
||||
dpi?: number;
|
||||
pixelRatio: number;
|
||||
}
|
||||
|
||||
export function getGuacamoleDisplaySize(
|
||||
cssWidth: number,
|
||||
cssHeight: number,
|
||||
protocol: string | undefined,
|
||||
devicePixelRatio: number,
|
||||
configuredDpi?: number,
|
||||
): GuacamoleDisplaySize {
|
||||
const isRdp = protocol === "rdp";
|
||||
const pixelRatio = isRdp
|
||||
? Math.min(
|
||||
MAX_DEVICE_PIXEL_RATIO,
|
||||
Math.max(1, Number.isFinite(devicePixelRatio) ? devicePixelRatio : 1),
|
||||
)
|
||||
: 1;
|
||||
|
||||
const size = {
|
||||
width: Math.max(1, Math.round(cssWidth * pixelRatio)),
|
||||
height: Math.max(1, Math.round(cssHeight * pixelRatio)),
|
||||
pixelRatio,
|
||||
};
|
||||
|
||||
if (!isRdp) return size;
|
||||
|
||||
const baseDpi =
|
||||
configuredDpi && Number.isFinite(configuredDpi) && configuredDpi > 0
|
||||
? configuredDpi
|
||||
: DEFAULT_RDP_DPI;
|
||||
return { ...size, dpi: Math.round(baseDpi * pixelRatio) };
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
export type FileDropDisposition = "ignore" | "reject" | "upload";
|
||||
|
||||
export function hasDraggedFiles(types: readonly string[]): boolean {
|
||||
return types.includes("Files");
|
||||
}
|
||||
|
||||
export function getFileDropDisposition(
|
||||
types: readonly string[],
|
||||
fileCount: number,
|
||||
canUpload: boolean,
|
||||
): FileDropDisposition {
|
||||
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,20 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describeUploadError } from "./guacamole-filesystem";
|
||||
|
||||
const t = (key: string) => `#${key}`;
|
||||
|
||||
describe("describeUploadError", () => {
|
||||
it("explains guacd's refusal to open the target file as a drive-folder problem", () => {
|
||||
expect(describeUploadError(new Error("FAIL (CANNOT OPEN)"), t)).toBe(
|
||||
"#driveNotWritable",
|
||||
);
|
||||
expect(describeUploadError(new Error("FAIL (NO FS)"), t)).toBe(
|
||||
"#driveUnavailable",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes other messages through and falls back for unknown errors", () => {
|
||||
expect(describeUploadError(new Error("disk full"), t)).toBe("disk full");
|
||||
expect(describeUploadError(undefined, t)).toBe("#uploadFailed");
|
||||
});
|
||||
});
|
||||
@@ -1,223 +0,0 @@
|
||||
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");
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
export type MetaKeyFamily = "win" | "super" | "cmd";
|
||||
|
||||
export interface MetaKeyLabels {
|
||||
family: MetaKeyFamily;
|
||||
short: string;
|
||||
lock: string;
|
||||
stickyKey: string;
|
||||
keyTooltip: string;
|
||||
lockTooltip: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* RDP is a Windows desktop, so the toolbar keeps Windows labels. VNC is used
|
||||
* for Linux and macOS as often as Windows; branding those sessions with a
|
||||
* Windows key is wrong, and the local device is the only OS we can see.
|
||||
*/
|
||||
export function detectMetaKeyFamily(
|
||||
protocol: "rdp" | "vnc" | "telnet",
|
||||
env: {
|
||||
platform?: string;
|
||||
userAgent?: string;
|
||||
userAgentDataPlatform?: string;
|
||||
} = {},
|
||||
): MetaKeyFamily {
|
||||
if (protocol === "rdp") return "win";
|
||||
|
||||
const uaPlatform = env.userAgentDataPlatform ?? "";
|
||||
const platform = env.platform ?? "";
|
||||
const userAgent = env.userAgent ?? "";
|
||||
const haystack = `${uaPlatform} ${platform} ${userAgent}`;
|
||||
|
||||
if (/iphone|ipad|ipod|mac/i.test(haystack)) return "cmd";
|
||||
if (/win/i.test(haystack)) return "win";
|
||||
return "super";
|
||||
}
|
||||
|
||||
export function detectRuntimeMetaKeyFamily(
|
||||
protocol: "rdp" | "vnc" | "telnet",
|
||||
): MetaKeyFamily {
|
||||
if (typeof navigator === "undefined") {
|
||||
return detectMetaKeyFamily(protocol);
|
||||
}
|
||||
const uaData = navigator as Navigator & {
|
||||
userAgentData?: { platform?: string };
|
||||
};
|
||||
return detectMetaKeyFamily(protocol, {
|
||||
platform: navigator.platform,
|
||||
userAgent: navigator.userAgent,
|
||||
userAgentDataPlatform: uaData.userAgentData?.platform,
|
||||
});
|
||||
}
|
||||
|
||||
export function metaKeyLabels(family: MetaKeyFamily): MetaKeyLabels {
|
||||
if (family === "cmd") {
|
||||
return {
|
||||
family,
|
||||
short: "Cmd",
|
||||
lock: "Cmd+L",
|
||||
stickyKey: "guacamole.toolbar.cmd",
|
||||
keyTooltip: "guacamole.toolbar.cmdKey",
|
||||
lockTooltip: "guacamole.toolbar.cmdL",
|
||||
};
|
||||
}
|
||||
if (family === "super") {
|
||||
return {
|
||||
family,
|
||||
short: "Super",
|
||||
lock: "Super+L",
|
||||
stickyKey: "guacamole.toolbar.super",
|
||||
keyTooltip: "guacamole.toolbar.superKey",
|
||||
lockTooltip: "guacamole.toolbar.superL",
|
||||
};
|
||||
}
|
||||
return {
|
||||
family,
|
||||
short: "Win",
|
||||
lock: "Win+L",
|
||||
stickyKey: "guacamole.toolbar.win",
|
||||
keyTooltip: "guacamole.toolbar.winKey",
|
||||
lockTooltip: "guacamole.toolbar.winL",
|
||||
};
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import Guacamole from "guacamole-common-js";
|
||||
import {
|
||||
createScrollCoalescer,
|
||||
isScrollButtonState,
|
||||
} from "./guacamole-scroll.js";
|
||||
|
||||
export type GuacamoleTouchMode = "touchscreen" | "touchpad";
|
||||
|
||||
/**
|
||||
* Guacamole.Mouse listens for mousedown/mousemove/mouseup; the Touchscreen and
|
||||
* Touchpad emulators listen only for touch events. The two sets do not overlap,
|
||||
* so binding one instead of the other leaves that input dead.
|
||||
*
|
||||
* A touch-capable device is not a device without a pointer: laptops with a
|
||||
* touchscreen report maxTouchPoints > 0 and are still driven by a mouse. The
|
||||
* physical pointer is therefore always bound, and a touch emulator is layered
|
||||
* on top when one is selected.
|
||||
*/
|
||||
export function bindPointerInput(
|
||||
element: HTMLElement,
|
||||
touchMode: GuacamoleTouchMode | null | undefined,
|
||||
onState: (state: Guacamole.Mouse.State) => void,
|
||||
): () => void {
|
||||
const coalescer = createScrollCoalescer((state) => {
|
||||
onState(state as Guacamole.Mouse.State);
|
||||
});
|
||||
let expectingScrollRelease = false;
|
||||
|
||||
const handleState = (state: Guacamole.Mouse.State) => {
|
||||
coalescer.notePointer(state);
|
||||
if (isScrollButtonState(state)) {
|
||||
coalescer.ingestClick(state.down ? 1 : -1);
|
||||
expectingScrollRelease = true;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
expectingScrollRelease &&
|
||||
!state.left &&
|
||||
!state.middle &&
|
||||
!state.right
|
||||
) {
|
||||
expectingScrollRelease = false;
|
||||
return;
|
||||
}
|
||||
expectingScrollRelease = false;
|
||||
onState(state);
|
||||
};
|
||||
|
||||
const mouse = new Guacamole.Mouse(element);
|
||||
mouse.onmousedown = mouse.onmouseup = mouse.onmousemove = handleState;
|
||||
|
||||
if (touchMode === "touchscreen") {
|
||||
const touchscreen = new Guacamole.Mouse.Touchscreen(element);
|
||||
touchscreen.onEach(["mousedown", "mousemove", "mouseup"], (event) =>
|
||||
handleState(event.state),
|
||||
);
|
||||
} else if (touchMode === "touchpad") {
|
||||
const touchpad = new Guacamole.Mouse.Touchpad(element);
|
||||
touchpad.onEach(["mousedown", "mousemove", "mouseup"], (event) =>
|
||||
handleState(event.state),
|
||||
);
|
||||
}
|
||||
|
||||
return () => coalescer.dispose();
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
/**
|
||||
* guacamole-common-js turns each wheel tick into one or more mouse-button
|
||||
* clicks (button 4/5). High-resolution trackpads and the library's duplicate
|
||||
* wheel/mousewheel listeners can emit dozens of those clicks in a single
|
||||
* gesture. Each click is two Guacamole instructions, so a laggy path (a
|
||||
* Cloudflare-proxied remote session especially) queues them and the remote
|
||||
* desktop keeps scrolling long after the user stopped.
|
||||
*
|
||||
* Cap the in-flight click count and space flushes out so scrolling stays
|
||||
* usable without changing the websocket proxy.
|
||||
*/
|
||||
export const MAX_SCROLL_CLICKS_PER_FLUSH = 2;
|
||||
export const SCROLL_FLUSH_INTERVAL_MS = 32;
|
||||
|
||||
export type ScrollClickDirection = 1 | -1;
|
||||
|
||||
export interface GuacamolePointerState {
|
||||
x: number;
|
||||
y: number;
|
||||
left: boolean;
|
||||
middle: boolean;
|
||||
right: boolean;
|
||||
up: boolean;
|
||||
down: boolean;
|
||||
}
|
||||
|
||||
export function clampPendingScrollClicks(
|
||||
pending: number,
|
||||
delta: number,
|
||||
max = MAX_SCROLL_CLICKS_PER_FLUSH,
|
||||
): number {
|
||||
return Math.max(-max, Math.min(max, pending + delta));
|
||||
}
|
||||
|
||||
export function takeScrollClicks(pending: number): {
|
||||
clicks: number;
|
||||
direction: ScrollClickDirection | 0;
|
||||
} {
|
||||
if (pending === 0) return { clicks: 0, direction: 0 };
|
||||
const direction: ScrollClickDirection = pending < 0 ? -1 : 1;
|
||||
return {
|
||||
clicks: Math.min(MAX_SCROLL_CLICKS_PER_FLUSH, Math.abs(pending)),
|
||||
direction,
|
||||
};
|
||||
}
|
||||
|
||||
function mouseState(
|
||||
x: number,
|
||||
y: number,
|
||||
buttons: Pick<GuacamolePointerState, "left" | "middle" | "right">,
|
||||
up: boolean,
|
||||
down: boolean,
|
||||
): GuacamolePointerState {
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
left: buttons.left,
|
||||
middle: buttons.middle,
|
||||
right: buttons.right,
|
||||
up,
|
||||
down,
|
||||
};
|
||||
}
|
||||
|
||||
export function createScrollCoalescer(
|
||||
send: (state: GuacamolePointerState) => void,
|
||||
now: () => number = () => Date.now(),
|
||||
schedule: (fn: () => void, ms: number) => number = (fn, ms) =>
|
||||
window.setTimeout(fn, ms),
|
||||
cancel: (id: number) => void = (id) => window.clearTimeout(id),
|
||||
): {
|
||||
notePointer: (state: GuacamolePointerState) => void;
|
||||
ingestClick: (direction: ScrollClickDirection) => void;
|
||||
dispose: () => void;
|
||||
} {
|
||||
let pending = 0;
|
||||
let timer: number | null = null;
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
let buttons = { left: false, middle: false, right: false };
|
||||
let lastFlushAt = 0;
|
||||
|
||||
const flush = () => {
|
||||
timer = null;
|
||||
const { clicks, direction } = takeScrollClicks(pending);
|
||||
pending = 0;
|
||||
if (clicks === 0 || direction === 0) return;
|
||||
lastFlushAt = now();
|
||||
const up = direction < 0;
|
||||
const down = direction > 0;
|
||||
for (let i = 0; i < clicks; i++) {
|
||||
send(mouseState(x, y, buttons, up, down));
|
||||
send(mouseState(x, y, buttons, false, false));
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
notePointer(state) {
|
||||
x = state.x;
|
||||
y = state.y;
|
||||
buttons = {
|
||||
left: state.left,
|
||||
middle: state.middle,
|
||||
right: state.right,
|
||||
};
|
||||
},
|
||||
ingestClick(direction) {
|
||||
pending = clampPendingScrollClicks(pending, direction);
|
||||
const elapsed = now() - lastFlushAt;
|
||||
if (elapsed >= SCROLL_FLUSH_INTERVAL_MS) {
|
||||
flush();
|
||||
return;
|
||||
}
|
||||
if (timer == null) {
|
||||
timer = schedule(flush, SCROLL_FLUSH_INTERVAL_MS - elapsed);
|
||||
}
|
||||
},
|
||||
dispose() {
|
||||
if (timer == null) return;
|
||||
cancel(timer);
|
||||
timer = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function isScrollButtonState(state: {
|
||||
up?: boolean;
|
||||
down?: boolean;
|
||||
}): boolean {
|
||||
return state.up === true || state.down === true;
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { getGuacamoleConnectionId } from "@/api/guacamole-api";
|
||||
import type { ConnectionOrigin } from "@/lib/connection-origin";
|
||||
|
||||
export function watchGuacamoleConnectionId(
|
||||
connectId: string,
|
||||
origin: ConnectionOrigin,
|
||||
onReady: (id: string) => void,
|
||||
): () => void {
|
||||
const controller = new AbortController();
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let attempts = 0;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const id = await getGuacamoleConnectionId(
|
||||
connectId,
|
||||
origin,
|
||||
controller.signal,
|
||||
);
|
||||
if (controller.signal.aborted) return;
|
||||
if (id) onReady(id);
|
||||
else if (++attempts < 20) timer = setTimeout(poll, 500);
|
||||
} catch {
|
||||
// Session sharing discovery must not interrupt the remote display.
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
return () => {
|
||||
controller.abort();
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
export function buildGuacamoleWebSocketBaseUrl({
|
||||
isDev,
|
||||
isElectronApp,
|
||||
isEmbeddedApp,
|
||||
configuredServerUrl,
|
||||
basePath,
|
||||
location,
|
||||
}: {
|
||||
isDev: boolean;
|
||||
isElectronApp: boolean;
|
||||
isEmbeddedApp: boolean;
|
||||
configuredServerUrl?: string;
|
||||
basePath: string;
|
||||
location: Pick<Location, "protocol" | "host">;
|
||||
}) {
|
||||
if (isDev) return "ws://localhost:30008";
|
||||
if (isElectronApp) {
|
||||
if (isEmbeddedApp || !configuredServerUrl) return "ws://127.0.0.1:30008";
|
||||
|
||||
const wsProtocol = configuredServerUrl.startsWith("https://")
|
||||
? "wss://"
|
||||
: "ws://";
|
||||
const wsHost = configuredServerUrl
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/\/$/, "");
|
||||
return `${wsProtocol}${wsHost}/guacamole/websocket/`;
|
||||
}
|
||||
|
||||
const wsProtocol = location.protocol === "https:" ? "wss" : "ws";
|
||||
return `${wsProtocol}://${location.host}${basePath}/guacamole/websocket/`;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { clampGuacamoleZoom, stepGuacamoleZoom } from "./guacamole-zoom.js";
|
||||
|
||||
describe("guacamole zoom", () => {
|
||||
it("steps in predictable quarter increments", () => {
|
||||
expect(stepGuacamoleZoom(1, 1)).toBe(1.25);
|
||||
expect(stepGuacamoleZoom(1, -1)).toBe(0.75);
|
||||
});
|
||||
|
||||
it("keeps toolbar and pinch zoom within usable bounds", () => {
|
||||
expect(clampGuacamoleZoom(0.1)).toBe(0.5);
|
||||
expect(clampGuacamoleZoom(8)).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
export const MIN_GUACAMOLE_ZOOM = 0.5;
|
||||
export const MAX_GUACAMOLE_ZOOM = 4;
|
||||
export const GUACAMOLE_ZOOM_STEP = 0.25;
|
||||
|
||||
export function clampGuacamoleZoom(zoom: number): number {
|
||||
return Math.min(MAX_GUACAMOLE_ZOOM, Math.max(MIN_GUACAMOLE_ZOOM, zoom));
|
||||
}
|
||||
|
||||
export function stepGuacamoleZoom(zoom: number, direction: -1 | 1): number {
|
||||
return clampGuacamoleZoom(zoom + direction * GUACAMOLE_ZOOM_STEP);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { HostAuthOverrides } from "@/types/auth-protocols";
|
||||
|
||||
export function needsRdpCredentialPrompt({
|
||||
protocol,
|
||||
rdpAuthType,
|
||||
authOverrides,
|
||||
}: {
|
||||
protocol: "rdp" | "vnc" | "telnet";
|
||||
rdpAuthType?: string;
|
||||
authOverrides?: HostAuthOverrides;
|
||||
}): boolean {
|
||||
return (
|
||||
protocol === "rdp" &&
|
||||
rdpAuthType === "none" &&
|
||||
!authOverrides?.rdp?.credentialId
|
||||
);
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
const authApiMock = vi.hoisted(() => ({
|
||||
get: vi.fn(async () => ({ data: { guacd: { status: "disconnected" } } })),
|
||||
post: vi.fn(async () => ({ data: { token: "local-token" } })),
|
||||
}));
|
||||
const remoteApiMock = vi.hoisted(() => ({
|
||||
get: vi.fn(async () => ({ data: { guacd: { status: "connected" } } })),
|
||||
post: vi.fn(async () => ({ data: { token: "remote-token" } })),
|
||||
}));
|
||||
const isElectronMock = vi.hoisted(() => vi.fn(() => false));
|
||||
const resolveRemoteHostIdMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/main-axios", () => ({
|
||||
authApi: authApiMock,
|
||||
getRemoteGuacamoleApi: () => remoteApiMock,
|
||||
isElectron: isElectronMock,
|
||||
handleApiError: (error: unknown) => error,
|
||||
}));
|
||||
vi.mock("@/lib/remote-server-api", () => ({
|
||||
resolveRemoteHostId: resolveRemoteHostIdMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
getGuacdStatus,
|
||||
getGuacamoleConnectionId,
|
||||
getGuacamoleTokenFromHost,
|
||||
} from "../../api/guacamole-api";
|
||||
|
||||
beforeEach(() => {
|
||||
authApiMock.get.mockClear();
|
||||
authApiMock.post.mockClear();
|
||||
remoteApiMock.get.mockClear();
|
||||
remoteApiMock.post.mockClear();
|
||||
resolveRemoteHostIdMock.mockReset();
|
||||
});
|
||||
|
||||
describe("guacamole API origin", () => {
|
||||
it("uses the shared instance in the browser", async () => {
|
||||
isElectronMock.mockReturnValue(false);
|
||||
|
||||
await getGuacdStatus("remote");
|
||||
await getGuacamoleTokenFromHost(9, "remote", "vnc");
|
||||
|
||||
expect(authApiMock.get).toHaveBeenCalledWith("/guacamole/status");
|
||||
expect(authApiMock.post).toHaveBeenCalledOnce();
|
||||
expect(remoteApiMock.get).not.toHaveBeenCalled();
|
||||
expect(remoteApiMock.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the connected remote server in the desktop app", async () => {
|
||||
isElectronMock.mockReturnValue(true);
|
||||
|
||||
// The embedded backend has no guacd, so asking it reports "disconnected"
|
||||
// even when the connected server can serve the session.
|
||||
const status = await getGuacdStatus("remote");
|
||||
const token = await getGuacamoleTokenFromHost(9, "remote", "vnc");
|
||||
|
||||
expect(status.guacd.status).toBe("connected");
|
||||
expect(token.token).toBe("remote-token");
|
||||
expect(remoteApiMock.get).toHaveBeenCalledWith("/guacamole/status");
|
||||
expect(remoteApiMock.post).toHaveBeenCalledOnce();
|
||||
expect(authApiMock.get).not.toHaveBeenCalled();
|
||||
expect(authApiMock.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends the connect-host payload unchanged to the remote server", async () => {
|
||||
isElectronMock.mockReturnValue(true);
|
||||
resolveRemoteHostIdMock.mockResolvedValue(41);
|
||||
|
||||
await getGuacamoleTokenFromHost(
|
||||
9,
|
||||
"remote",
|
||||
"rdp",
|
||||
{
|
||||
username: "admin",
|
||||
password: "secret",
|
||||
domain: "EXAMPLE",
|
||||
},
|
||||
"host-sync-id",
|
||||
);
|
||||
|
||||
expect(remoteApiMock.post).toHaveBeenCalledWith(
|
||||
"/guacamole/connect-host/41",
|
||||
{
|
||||
protocol: "rdp",
|
||||
promptedUsername: "admin",
|
||||
promptedPassword: "secret",
|
||||
promptedDomain: "EXAMPLE",
|
||||
},
|
||||
);
|
||||
expect(resolveRemoteHostIdMock).toHaveBeenCalledWith("host-sync-id");
|
||||
});
|
||||
|
||||
it("sends an empty prompted domain for local RDP accounts", async () => {
|
||||
isElectronMock.mockReturnValue(false);
|
||||
|
||||
await getGuacamoleTokenFromHost(9, "local", "rdp", {
|
||||
username: "local-admin",
|
||||
password: "secret",
|
||||
domain: "",
|
||||
});
|
||||
|
||||
expect(authApiMock.post).toHaveBeenCalledWith("/guacamole/connect-host/9", {
|
||||
protocol: "rdp",
|
||||
promptedUsername: "local-admin",
|
||||
promptedPassword: "secret",
|
||||
promptedDomain: "",
|
||||
});
|
||||
});
|
||||
|
||||
// Support#1240: a host set to "This Device" is served by the embedded
|
||||
// backend, so its token has to come from there too -- and its id must not
|
||||
// be remapped onto a remote server that may not even be configured.
|
||||
it("asks the embedded backend when the host originates locally", async () => {
|
||||
isElectronMock.mockReturnValue(true);
|
||||
|
||||
const token = await getGuacamoleTokenFromHost(9, "local", "rdp");
|
||||
|
||||
expect(token.token).toBe("local-token");
|
||||
expect(authApiMock.post).toHaveBeenCalledWith("/guacamole/connect-host/9", {
|
||||
protocol: "rdp",
|
||||
});
|
||||
expect(remoteApiMock.post).not.toHaveBeenCalled();
|
||||
expect(resolveRemoteHostIdMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports the embedded backend's own guacd for a local origin", async () => {
|
||||
isElectronMock.mockReturnValue(true);
|
||||
|
||||
const status = await getGuacdStatus("local");
|
||||
|
||||
expect(status.guacd.status).toBe("disconnected");
|
||||
expect(authApiMock.get).toHaveBeenCalledWith("/guacamole/status");
|
||||
expect(remoteApiMock.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the remote server for an explicitly remote origin", async () => {
|
||||
isElectronMock.mockReturnValue(true);
|
||||
|
||||
await getGuacamoleTokenFromHost(9, "remote", "rdp");
|
||||
|
||||
expect(remoteApiMock.post).toHaveBeenCalledOnce();
|
||||
expect(authApiMock.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not fall back to a colliding local id when sync resolution fails", async () => {
|
||||
isElectronMock.mockReturnValue(true);
|
||||
resolveRemoteHostIdMock.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
getGuacamoleTokenFromHost(
|
||||
9,
|
||||
"remote",
|
||||
"rdp",
|
||||
undefined,
|
||||
"missing-sync-id",
|
||||
),
|
||||
).rejects.toThrow("The synced host does not exist on the remote server");
|
||||
expect(remoteApiMock.post).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("looks up the session on the backend that issued its token", async () => {
|
||||
isElectronMock.mockReturnValue(true);
|
||||
const signal = new AbortController().signal;
|
||||
await getGuacamoleConnectionId("connect/id", "remote", signal);
|
||||
expect(remoteApiMock.get).toHaveBeenCalledWith(
|
||||
"/guacamole/connection/connect%2Fid",
|
||||
{ signal },
|
||||
);
|
||||
expect(authApiMock.get).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildGuacamoleWebSocketBaseUrl } from "../../../features/guacamole/guacamole-websocket-url.js";
|
||||
|
||||
const httpsLocation = {
|
||||
protocol: "https:",
|
||||
host: "termix.example.com",
|
||||
} as Location;
|
||||
|
||||
describe("buildGuacamoleWebSocketBaseUrl", () => {
|
||||
it("uses the same origin guacamole websocket path in production web builds", () => {
|
||||
expect(
|
||||
buildGuacamoleWebSocketBaseUrl({
|
||||
isDev: false,
|
||||
isElectronApp: false,
|
||||
isEmbeddedApp: false,
|
||||
basePath: "",
|
||||
location: httpsLocation,
|
||||
}),
|
||||
).toBe("wss://termix.example.com/guacamole/websocket/");
|
||||
});
|
||||
|
||||
it("preserves the runtime base path for proxied web deployments", () => {
|
||||
expect(
|
||||
buildGuacamoleWebSocketBaseUrl({
|
||||
isDev: false,
|
||||
isElectronApp: false,
|
||||
isEmbeddedApp: false,
|
||||
basePath: "/termix",
|
||||
location: httpsLocation,
|
||||
}),
|
||||
).toBe("wss://termix.example.com/termix/guacamole/websocket/");
|
||||
});
|
||||
|
||||
it("keeps direct local websocket access for development and embedded electron", () => {
|
||||
expect(
|
||||
buildGuacamoleWebSocketBaseUrl({
|
||||
isDev: true,
|
||||
isElectronApp: false,
|
||||
isEmbeddedApp: false,
|
||||
basePath: "/termix",
|
||||
location: httpsLocation,
|
||||
}),
|
||||
).toBe("ws://localhost:30008");
|
||||
|
||||
expect(
|
||||
buildGuacamoleWebSocketBaseUrl({
|
||||
isDev: false,
|
||||
isElectronApp: true,
|
||||
isEmbeddedApp: true,
|
||||
basePath: "/termix",
|
||||
location: httpsLocation,
|
||||
}),
|
||||
).toBe("ws://127.0.0.1:30008");
|
||||
});
|
||||
|
||||
it("uses the configured remote server URL for electron remote mode", () => {
|
||||
expect(
|
||||
buildGuacamoleWebSocketBaseUrl({
|
||||
isDev: false,
|
||||
isElectronApp: true,
|
||||
isEmbeddedApp: false,
|
||||
configuredServerUrl: "https://termix.example.com/termix/",
|
||||
basePath: "",
|
||||
location: httpsLocation,
|
||||
}),
|
||||
).toBe("wss://termix.example.com/termix/guacamole/websocket/");
|
||||
});
|
||||
});
|
||||
@@ -1,144 +0,0 @@
|
||||
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";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
describe("GuacamoleToolbar Windows key", () => {
|
||||
it("sends Super_L for Win shortcuts and the sticky modifier", () => {
|
||||
const sendKey = vi.fn();
|
||||
const displayRef = {
|
||||
current: {
|
||||
disconnect: vi.fn(),
|
||||
isConnected: () => true,
|
||||
sendKey,
|
||||
sendMouse: vi.fn(),
|
||||
setClipboard: vi.fn(),
|
||||
getFilesystem: () => null,
|
||||
uploadFile: async () => {},
|
||||
zoomIn: vi.fn(() => 1.25),
|
||||
zoomOut: vi.fn(() => 0.75),
|
||||
resetZoom: vi.fn(() => 1),
|
||||
} satisfies GuacamoleDisplayHandle,
|
||||
} as React.RefObject<GuacamoleDisplayHandle>;
|
||||
const { getByText } = render(
|
||||
<GuacamoleToolbar
|
||||
displayRef={displayRef}
|
||||
protocol="rdp"
|
||||
metaKeyFamily="win"
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(getByText("Win+L"));
|
||||
expect(sendKey.mock.calls).toEqual([
|
||||
[0xffeb, true],
|
||||
[0x006c, true],
|
||||
[0x006c, false],
|
||||
[0xffeb, false],
|
||||
]);
|
||||
|
||||
sendKey.mockClear();
|
||||
fireEvent.click(getByText("Win"));
|
||||
expect(sendKey.mock.calls).toEqual([
|
||||
[0xffeb, true],
|
||||
[0xffeb, false],
|
||||
]);
|
||||
|
||||
sendKey.mockClear();
|
||||
fireEvent.click(getByText("guacamole.toolbar.win"));
|
||||
expect(sendKey).toHaveBeenCalledWith(0xffeb, true);
|
||||
});
|
||||
|
||||
it("exposes VNC zoom controls without showing them for RDP", () => {
|
||||
const zoomIn = vi.fn(() => 1.25);
|
||||
const zoomOut = vi.fn(() => 0.75);
|
||||
const resetZoom = vi.fn(() => 1);
|
||||
const displayRef = {
|
||||
current: {
|
||||
disconnect: vi.fn(),
|
||||
isConnected: () => true,
|
||||
sendKey: vi.fn(),
|
||||
sendMouse: vi.fn(),
|
||||
setClipboard: vi.fn(),
|
||||
getFilesystem: () => null,
|
||||
uploadFile: async () => {},
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
resetZoom,
|
||||
} satisfies GuacamoleDisplayHandle,
|
||||
} as React.RefObject<GuacamoleDisplayHandle>;
|
||||
const { getByLabelText, getByText } = render(
|
||||
<GuacamoleToolbar displayRef={displayRef} protocol="vnc" zoom={1.25} />,
|
||||
);
|
||||
|
||||
fireEvent.click(getByLabelText("guacamole.toolbar.zoomOut"));
|
||||
fireEvent.click(getByLabelText("guacamole.toolbar.zoomIn"));
|
||||
fireEvent.click(getByText("125%"));
|
||||
expect(zoomOut).toHaveBeenCalledOnce();
|
||||
expect(zoomIn).toHaveBeenCalledOnce();
|
||||
expect(resetZoom).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("offers an explicit session-only hide action", () => {
|
||||
const onHide = vi.fn();
|
||||
const displayRef = {
|
||||
current: {
|
||||
disconnect: vi.fn(),
|
||||
isConnected: () => true,
|
||||
sendKey: vi.fn(),
|
||||
sendMouse: vi.fn(),
|
||||
setClipboard: vi.fn(),
|
||||
getFilesystem: () => null,
|
||||
uploadFile: vi.fn(),
|
||||
zoomIn: vi.fn(() => 1.25),
|
||||
zoomOut: vi.fn(() => 0.75),
|
||||
resetZoom: vi.fn(() => 1),
|
||||
} satisfies GuacamoleDisplayHandle,
|
||||
} as React.RefObject<GuacamoleDisplayHandle>;
|
||||
|
||||
const { getByLabelText } = render(
|
||||
<GuacamoleToolbar
|
||||
displayRef={displayRef}
|
||||
protocol="rdp"
|
||||
onHide={onHide}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(getByLabelText("guacamole.toolbar.hide"));
|
||||
expect(onHide).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("labels the VNC meta key Super instead of Windows", () => {
|
||||
const displayRef = {
|
||||
current: {
|
||||
disconnect: vi.fn(),
|
||||
isConnected: () => true,
|
||||
sendKey: vi.fn(),
|
||||
sendMouse: vi.fn(),
|
||||
setClipboard: vi.fn(),
|
||||
getFilesystem: () => null,
|
||||
uploadFile: vi.fn(),
|
||||
zoomIn: vi.fn(() => 1.25),
|
||||
zoomOut: vi.fn(() => 0.75),
|
||||
resetZoom: vi.fn(() => 1),
|
||||
} satisfies GuacamoleDisplayHandle,
|
||||
} as React.RefObject<GuacamoleDisplayHandle>;
|
||||
|
||||
const { getByText, queryByText } = render(
|
||||
<GuacamoleToolbar
|
||||
displayRef={displayRef}
|
||||
protocol="vnc"
|
||||
metaKeyFamily="super"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByText("Super+L")).toBeTruthy();
|
||||
expect(getByText("Super")).toBeTruthy();
|
||||
expect(queryByText("Win+L")).toBeNull();
|
||||
expect(queryByText("Win")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
isPasteShortcut,
|
||||
pasteTextToRemote,
|
||||
type GuacamoleClipboardClient,
|
||||
} from "../../../features/guacamole/guacamole-clipboard.js";
|
||||
|
||||
describe("Guacamole clipboard paste", () => {
|
||||
it("recognizes Ctrl+V and Command+V without intercepting Alt+V", () => {
|
||||
expect(
|
||||
isPasteShortcut({
|
||||
key: "v",
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
altKey: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPasteShortcut({
|
||||
key: "V",
|
||||
ctrlKey: false,
|
||||
metaKey: true,
|
||||
altKey: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPasteShortcut({
|
||||
key: "v",
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
altKey: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("updates the remote clipboard before sending Ctrl+V", () => {
|
||||
const events: string[] = [];
|
||||
const client: GuacamoleClipboardClient = {
|
||||
createClipboardStream: vi.fn((mimetype: string) => {
|
||||
events.push(`stream:${mimetype}`);
|
||||
return {
|
||||
sendBlob: () => events.push("blob"),
|
||||
sendEnd: () => events.push("end"),
|
||||
};
|
||||
}),
|
||||
sendKeyEvent: vi.fn((pressed: number, keysym: number) => {
|
||||
events.push(`key:${pressed}:${keysym}`);
|
||||
}),
|
||||
};
|
||||
|
||||
pasteTextToRemote(client, "Firefox clipboard");
|
||||
|
||||
expect(events).toEqual([
|
||||
"stream:text/plain",
|
||||
"blob",
|
||||
"end",
|
||||
"key:1:65507",
|
||||
"key:1:118",
|
||||
"key:0:118",
|
||||
"key:0:65507",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getGuacamoleDisplaySize,
|
||||
readConfiguredDimension,
|
||||
} from "../../../features/guacamole/guacamole-display-size";
|
||||
|
||||
describe("getGuacamoleDisplaySize", () => {
|
||||
it("requests native pixels and matching DPI for HiDPI RDP", () => {
|
||||
expect(getGuacamoleDisplaySize(1280, 720, "rdp", 2)).toEqual({
|
||||
width: 2560,
|
||||
height: 1440,
|
||||
dpi: 192,
|
||||
pixelRatio: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("scales a configured RDP DPI with the device pixel ratio", () => {
|
||||
expect(getGuacamoleDisplaySize(1000, 600, "rdp", 1.5, 120)).toEqual({
|
||||
width: 1500,
|
||||
height: 900,
|
||||
dpi: 180,
|
||||
pixelRatio: 1.5,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves non-RDP protocols at CSS-pixel dimensions", () => {
|
||||
expect(getGuacamoleDisplaySize(1280, 720, "vnc", 2)).toEqual({
|
||||
width: 1280,
|
||||
height: 720,
|
||||
pixelRatio: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("caps pathological pixel ratios to protect remote session size", () => {
|
||||
expect(getGuacamoleDisplaySize(400, 800, "rdp", 4)).toEqual({
|
||||
width: 1200,
|
||||
height: 2400,
|
||||
dpi: 288,
|
||||
pixelRatio: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses a configured resolution in place of the container size", () => {
|
||||
expect(
|
||||
getGuacamoleDisplaySize(800, 600, "rdp", 1, undefined),
|
||||
).toMatchObject({ width: 800, height: 600 });
|
||||
expect(
|
||||
getGuacamoleDisplaySize(1920, 1080, "rdp", 1, undefined),
|
||||
).toMatchObject({ width: 1920, height: 1080 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("readConfiguredDimension", () => {
|
||||
it("accepts the strings the host editor stores", () => {
|
||||
expect(readConfiguredDimension("1920")).toBe(1920);
|
||||
expect(readConfiguredDimension(1080)).toBe(1080);
|
||||
});
|
||||
|
||||
it("treats an unset or unusable value as 'follow the container'", () => {
|
||||
expect(readConfiguredDimension("")).toBeUndefined();
|
||||
expect(readConfiguredDimension(undefined)).toBeUndefined();
|
||||
expect(readConfiguredDimension(null)).toBeUndefined();
|
||||
expect(readConfiguredDimension("auto")).toBeUndefined();
|
||||
expect(readConfiguredDimension("0")).toBeUndefined();
|
||||
expect(readConfiguredDimension("-1080")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
canUploadToRdpDrive,
|
||||
getFileDropDisposition,
|
||||
hasDraggedFiles,
|
||||
} from "@/features/guacamole/guacamole-file-drop.ts";
|
||||
|
||||
describe("Guacamole file drop", () => {
|
||||
it("recognizes external file drags", () => {
|
||||
expect(hasDraggedFiles(["Files"])).toBe(true);
|
||||
expect(hasDraggedFiles(["text/plain"])).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects files when upload is unavailable instead of ignoring them", () => {
|
||||
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,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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
detectMetaKeyFamily,
|
||||
metaKeyLabels,
|
||||
} from "../../../features/guacamole/guacamole-meta-key.js";
|
||||
|
||||
describe("guacamole toolbar meta key labels", () => {
|
||||
it("keeps Windows labels for RDP regardless of the local device", () => {
|
||||
expect(
|
||||
detectMetaKeyFamily("rdp", {
|
||||
platform: "MacIntel",
|
||||
userAgent: "Macintosh; Intel Mac OS X",
|
||||
userAgentDataPlatform: "macOS",
|
||||
}),
|
||||
).toBe("win");
|
||||
});
|
||||
|
||||
it("labels VNC with the local device's modifier instead of Windows", () => {
|
||||
expect(
|
||||
detectMetaKeyFamily("vnc", {
|
||||
platform: "MacIntel",
|
||||
userAgent: "Macintosh; Intel Mac OS X",
|
||||
userAgentDataPlatform: "macOS",
|
||||
}),
|
||||
).toBe("cmd");
|
||||
expect(
|
||||
detectMetaKeyFamily("vnc", {
|
||||
platform: "Linux x86_64",
|
||||
userAgent: "X11; Linux x86_64",
|
||||
userAgentDataPlatform: "Linux",
|
||||
}),
|
||||
).toBe("super");
|
||||
expect(
|
||||
detectMetaKeyFamily("vnc", {
|
||||
platform: "Win32",
|
||||
userAgent: "Windows NT 10.0",
|
||||
userAgentDataPlatform: "Windows",
|
||||
}),
|
||||
).toBe("win");
|
||||
});
|
||||
|
||||
it("exposes Super/Cmd button copy for non-Windows VNC sessions", () => {
|
||||
expect(metaKeyLabels("super").short).toBe("Super");
|
||||
expect(metaKeyLabels("super").lock).toBe("Super+L");
|
||||
expect(metaKeyLabels("cmd").short).toBe("Cmd");
|
||||
expect(metaKeyLabels("cmd").lock).toBe("Cmd+L");
|
||||
});
|
||||
});
|
||||
@@ -1,82 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { bindPointerInput } from "../../../features/guacamole/guacamole-pointer.js";
|
||||
|
||||
function listenedEvents(): {
|
||||
element: HTMLElement;
|
||||
events: () => string[];
|
||||
} {
|
||||
const element = document.createElement("div");
|
||||
const seen: string[] = [];
|
||||
const original = element.addEventListener.bind(element);
|
||||
element.addEventListener = ((type: string, ...rest: unknown[]) => {
|
||||
seen.push(type);
|
||||
return (original as (t: string, ...r: unknown[]) => void)(type, ...rest);
|
||||
}) as typeof element.addEventListener;
|
||||
return { element, events: () => seen };
|
||||
}
|
||||
|
||||
describe("bindPointerInput", () => {
|
||||
// A touchscreen laptop reports maxTouchPoints > 0 and still has a mouse.
|
||||
// Binding only the touch emulator left the pointer dead on those machines.
|
||||
it.each([
|
||||
["no touch mode", null],
|
||||
["touchscreen", "touchscreen" as const],
|
||||
["touchpad", "touchpad" as const],
|
||||
])("keeps the physical pointer bound with %s", (_label, touchMode) => {
|
||||
const { element, events } = listenedEvents();
|
||||
|
||||
bindPointerInput(element, touchMode, vi.fn());
|
||||
|
||||
expect(events()).toContain("mousedown");
|
||||
expect(events()).toContain("mousemove");
|
||||
expect(events()).toContain("mouseup");
|
||||
});
|
||||
|
||||
it("adds touch listeners only when a touch mode is selected", () => {
|
||||
const withoutTouch = listenedEvents();
|
||||
bindPointerInput(withoutTouch.element, null, vi.fn());
|
||||
const plainTouchCount = withoutTouch
|
||||
.events()
|
||||
.filter((e) => e.startsWith("touch")).length;
|
||||
|
||||
const withTouch = listenedEvents();
|
||||
bindPointerInput(withTouch.element, "touchscreen", vi.fn());
|
||||
const touchModeCount = withTouch
|
||||
.events()
|
||||
.filter((e) => e.startsWith("touch")).length;
|
||||
|
||||
expect(touchModeCount).toBeGreaterThan(plainTouchCount);
|
||||
});
|
||||
|
||||
it("does not forward a wheel flood as individual remote scroll clicks", () => {
|
||||
vi.useFakeTimers();
|
||||
const element = document.createElement("div");
|
||||
const onState = vi.fn();
|
||||
const dispose = bindPointerInput(element, null, onState);
|
||||
|
||||
for (let i = 0; i < 30; i++) {
|
||||
element.dispatchEvent(
|
||||
new WheelEvent("wheel", {
|
||||
deltaY: 120,
|
||||
deltaMode: 0,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const presses = onState.mock.calls.filter(
|
||||
([state]) => state.up === true || state.down === true,
|
||||
);
|
||||
expect(presses.length).toBeLessThanOrEqual(2);
|
||||
|
||||
vi.advanceTimersByTime(40);
|
||||
const laterPresses = onState.mock.calls.filter(
|
||||
([state]) => state.up === true || state.down === true,
|
||||
);
|
||||
expect(laterPresses.length).toBeLessThanOrEqual(4);
|
||||
|
||||
dispose();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -1,81 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clampPendingScrollClicks,
|
||||
createScrollCoalescer,
|
||||
isScrollButtonState,
|
||||
MAX_SCROLL_CLICKS_PER_FLUSH,
|
||||
SCROLL_FLUSH_INTERVAL_MS,
|
||||
takeScrollClicks,
|
||||
} from "../../../features/guacamole/guacamole-scroll.js";
|
||||
|
||||
describe("guacamole scroll coalescing", () => {
|
||||
it("caps a click flood so a laggy tunnel cannot queue a backlog", () => {
|
||||
expect(clampPendingScrollClicks(0, 40)).toBe(MAX_SCROLL_CLICKS_PER_FLUSH);
|
||||
expect(clampPendingScrollClicks(0, -40)).toBe(-MAX_SCROLL_CLICKS_PER_FLUSH);
|
||||
expect(takeScrollClicks(20)).toEqual({
|
||||
clicks: MAX_SCROLL_CLICKS_PER_FLUSH,
|
||||
direction: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("sends the first notch immediately and drops later clicks in the same burst", () => {
|
||||
const send = vi.fn();
|
||||
let now = 1_000;
|
||||
const scheduled: Array<{ fn: () => void; ms: number }> = [];
|
||||
const coalescer = createScrollCoalescer(
|
||||
send,
|
||||
() => now,
|
||||
(fn, ms) => {
|
||||
scheduled.push({ fn, ms });
|
||||
return scheduled.length;
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
|
||||
coalescer.notePointer({
|
||||
x: 12,
|
||||
y: 34,
|
||||
left: false,
|
||||
middle: false,
|
||||
right: false,
|
||||
up: false,
|
||||
down: false,
|
||||
});
|
||||
for (let i = 0; i < 20; i++) coalescer.ingestClick(1);
|
||||
|
||||
const firstBurst = send.mock.calls.map(([state]) => state);
|
||||
expect(firstBurst).toEqual([
|
||||
{
|
||||
x: 12,
|
||||
y: 34,
|
||||
left: false,
|
||||
middle: false,
|
||||
right: false,
|
||||
up: false,
|
||||
down: true,
|
||||
},
|
||||
{
|
||||
x: 12,
|
||||
y: 34,
|
||||
left: false,
|
||||
middle: false,
|
||||
right: false,
|
||||
up: false,
|
||||
down: false,
|
||||
},
|
||||
]);
|
||||
|
||||
send.mockClear();
|
||||
now += SCROLL_FLUSH_INTERVAL_MS;
|
||||
expect(scheduled).toHaveLength(1);
|
||||
scheduled[0].fn();
|
||||
expect(send.mock.calls).toHaveLength(MAX_SCROLL_CLICKS_PER_FLUSH * 2);
|
||||
coalescer.dispose();
|
||||
});
|
||||
|
||||
it("recognizes only the scroll buttons as coalesced input", () => {
|
||||
expect(isScrollButtonState({ up: true, down: false })).toBe(true);
|
||||
expect(isScrollButtonState({ up: false, down: true })).toBe(true);
|
||||
expect(isScrollButtonState({ up: false, down: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
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";
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
fetchId.mockReset();
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
it("discovers the sharing ID after handshake without changing the connection origin", async () => {
|
||||
fetchId.mockResolvedValueOnce(null).mockResolvedValueOnce("guacd-id");
|
||||
const ready = vi.fn();
|
||||
const stop = watchGuacamoleConnectionId("connect-id", "remote", ready);
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(fetchId).toHaveBeenLastCalledWith(
|
||||
"connect-id",
|
||||
"remote",
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
expect(ready).toHaveBeenCalledExactlyOnceWith("guacd-id");
|
||||
stop();
|
||||
});
|
||||
it("ignores a late response after disconnect or replacement", async () => {
|
||||
let resolve!: (id: string) => void;
|
||||
fetchId.mockReturnValue(
|
||||
new Promise<string>((done) => {
|
||||
resolve = done;
|
||||
}),
|
||||
);
|
||||
const ready = vi.fn();
|
||||
const stop = watchGuacamoleConnectionId("old", "local", ready);
|
||||
stop();
|
||||
resolve("old-guacd-id");
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(ready).not.toHaveBeenCalled();
|
||||
expect(fetchId.mock.calls[0][2].aborted).toBe(true);
|
||||
});
|
||||
it("bounds discovery when the session never opens", async () => {
|
||||
fetchId.mockResolvedValue(null);
|
||||
const stop = watchGuacamoleConnectionId("missing", "local", vi.fn());
|
||||
await vi.advanceTimersByTimeAsync(30000);
|
||||
expect(fetchId).toHaveBeenCalledTimes(20);
|
||||
stop();
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { needsRdpCredentialPrompt } from "@/features/guacamole/rdp-credential-prompt";
|
||||
|
||||
describe("needsRdpCredentialPrompt", () => {
|
||||
it("prompts recipients when RDP has no saved authentication", () => {
|
||||
expect(
|
||||
needsRdpCredentialPrompt({ protocol: "rdp", rdpAuthType: "none" }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not prompt when the recipient has a personal override", () => {
|
||||
expect(
|
||||
needsRdpCredentialPrompt({
|
||||
protocol: "rdp",
|
||||
rdpAuthType: "none",
|
||||
authOverrides: {
|
||||
rdp: { credentialId: 7, required: false, ownerAuthShared: true },
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not prompt for other protocols", () => {
|
||||
expect(
|
||||
needsRdpCredentialPrompt({ protocol: "vnc", rdpAuthType: "none" }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user