451 lines
15 KiB
TypeScript
451 lines
15 KiB
TypeScript
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { cn } from "@/lib/utils";
|
|
import { getWsBase } from "@/utils/request";
|
|
import { useSessionSecurityStore, useUserStore } from "@/store";
|
|
import { copyToClipboard, readFromClipboard } from "@/utils/clipboard";
|
|
import { SessionWatermark } from "@/components/common/SessionWatermark";
|
|
import { Loader2 } from "lucide-react";
|
|
import { RdpDialogHeader } from "./rdp-dialog/RdpDialogHeader";
|
|
import {
|
|
calcRdpViewport,
|
|
paintBitmap,
|
|
parseRawFrame,
|
|
readModifiers,
|
|
} from "./rdp-dialog/rdp-utils";
|
|
import {
|
|
RDP_HEADER_HEIGHT,
|
|
type KeyModifierState,
|
|
type RdpDialogProps,
|
|
type RdpFrame,
|
|
type RdpRawFrame,
|
|
type RdpStats,
|
|
} from "./rdp-dialog/types";
|
|
|
|
export function RdpDialog({
|
|
open,
|
|
onOpenChange,
|
|
serverId,
|
|
serverName,
|
|
serverIp,
|
|
}: RdpDialogProps) {
|
|
const { t } = useTranslation();
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const wsRef = useRef<WebSocket | null>(null);
|
|
const paintQueueRef = useRef<Promise<void>>(Promise.resolve());
|
|
const statsRef = useRef<RdpStats>({ received: 0, drawn: 0, failed: 0 });
|
|
const pendingMouseMoveRef = useRef<Record<string, unknown> | null>(null);
|
|
const mouseMoveTimerRef = useRef<number | null>(null);
|
|
const keyModifierRef = useRef<KeyModifierState>({
|
|
ctrl: false,
|
|
alt: false,
|
|
shift: false,
|
|
meta: false,
|
|
});
|
|
const [connected, setConnected] = useState(false);
|
|
const [fullscreen, setFullscreen] = useState(false);
|
|
const [status, setStatus] = useState(t("server.rdpDialog.waiting"));
|
|
const [hasFrame, setHasFrame] = useState(false);
|
|
const [canvasSize, setCanvasSize] = useState(() => calcRdpViewport(false));
|
|
const [displaySize, setDisplaySize] = useState(() => calcRdpViewport(false));
|
|
const [toolsOpen, setToolsOpen] = useState(false);
|
|
const [remoteClipboard, setRemoteClipboard] = useState("");
|
|
const clipboardEnabled = useSessionSecurityStore(
|
|
(state) => state.clipboardEnabled,
|
|
);
|
|
const fileDownloadEnabled = useSessionSecurityStore(
|
|
(state) => state.fileDownloadEnabled,
|
|
);
|
|
|
|
const sendEvent = (event: Record<string, unknown>) => {
|
|
const ws = wsRef.current;
|
|
if (ws?.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify(event));
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const flushMouseMove = () => {
|
|
if (mouseMoveTimerRef.current !== null) {
|
|
window.clearTimeout(mouseMoveTimerRef.current);
|
|
mouseMoveTimerRef.current = null;
|
|
}
|
|
const event = pendingMouseMoveRef.current;
|
|
pendingMouseMoveRef.current = null;
|
|
if (event) sendEvent(event);
|
|
};
|
|
|
|
const sendMouseMove = (event: Record<string, unknown>) => {
|
|
pendingMouseMoveRef.current = event;
|
|
if (mouseMoveTimerRef.current !== null) return;
|
|
// 拖拽时只保留最新坐标,按 16ms 发送,避免旧 mousemove 堆积造成左右摆延迟。 // Logan.Li
|
|
mouseMoveTimerRef.current = window.setTimeout(() => {
|
|
mouseMoveTimerRef.current = null;
|
|
const latest = pendingMouseMoveRef.current;
|
|
pendingMouseMoveRef.current = null;
|
|
if (latest) sendEvent(latest);
|
|
}, 16);
|
|
};
|
|
|
|
const patchKeyModifier = (
|
|
e: KeyboardEvent<HTMLCanvasElement>,
|
|
down: boolean,
|
|
) => {
|
|
const next = keyModifierRef.current;
|
|
if (e.code === "ControlLeft" || e.code === "ControlRight") next.ctrl = down;
|
|
if (e.code === "AltLeft" || e.code === "AltRight") next.alt = down;
|
|
if (e.code === "ShiftLeft" || e.code === "ShiftRight") next.shift = down;
|
|
if (e.code === "MetaLeft" || e.code === "MetaRight") next.meta = down;
|
|
};
|
|
|
|
const handleKeyDown = async (e: KeyboardEvent<HTMLCanvasElement>) => {
|
|
e.preventDefault();
|
|
patchKeyModifier(e, true);
|
|
const modifiers = readModifiers(e, keyModifierRef.current);
|
|
const pasteCombo = (e.metaKey || e.ctrlKey) && e.code === "KeyV";
|
|
if (pasteCombo) {
|
|
if (!clipboardEnabled) {
|
|
setStatus(t("server.sessionPolicy.clipboardDisabled"));
|
|
return;
|
|
}
|
|
const text = await readFromClipboard();
|
|
if (text) sendEvent({ type: "clipboard", text });
|
|
return;
|
|
}
|
|
sendEvent({
|
|
type: "key",
|
|
code: e.code,
|
|
key: e.key,
|
|
down: true,
|
|
...modifiers,
|
|
});
|
|
};
|
|
|
|
const handleKeyUp = (e: KeyboardEvent<HTMLCanvasElement>) => {
|
|
e.preventDefault();
|
|
if ((e.metaKey || e.ctrlKey) && e.code === "KeyV") return;
|
|
const modifiers = readModifiers(e, keyModifierRef.current);
|
|
patchKeyModifier(e, false);
|
|
sendEvent({
|
|
type: "key",
|
|
code: e.code,
|
|
key: e.key,
|
|
down: false,
|
|
...modifiers,
|
|
});
|
|
};
|
|
|
|
const requestRemoteCopy = () => {
|
|
// 工具按钮使用独立事件,避免和用户在远端窗口手按 Ctrl+C 的键盘状态互相污染。 // Logan.Li
|
|
if (!clipboardEnabled) {
|
|
setStatus(t("server.sessionPolicy.clipboardDisabled"));
|
|
return;
|
|
}
|
|
flushMouseMove();
|
|
sendEvent({ type: "remote_copy" });
|
|
setStatus(t("server.rdpDialog.remoteCopyRequested"));
|
|
};
|
|
|
|
const patchStats = (patch: Partial<RdpStats>) => {
|
|
statsRef.current = { ...statsRef.current, ...patch };
|
|
};
|
|
|
|
const drawRawFrame = async (data: Blob | ArrayBuffer) => {
|
|
const frame: RdpRawFrame | null = await parseRawFrame(data);
|
|
if (!frame) return;
|
|
setStatus("");
|
|
const canvas = canvasRef.current;
|
|
const ctx = canvas?.getContext("2d");
|
|
if (!canvas || !ctx) return;
|
|
ctx.imageSmoothingEnabled = false;
|
|
patchStats({ received: statsRef.current.received + 1 });
|
|
try {
|
|
const pixels = new Uint8ClampedArray(frame.rgba.length);
|
|
pixels.set(frame.rgba);
|
|
ctx.putImageData(
|
|
new ImageData(pixels, frame.width, frame.height),
|
|
frame.x,
|
|
frame.y,
|
|
);
|
|
setHasFrame(true);
|
|
patchStats({ drawn: statsRef.current.drawn + 1 });
|
|
} catch {
|
|
patchStats({ failed: statsRef.current.failed + 1 });
|
|
setStatus(t("server.rdpDialog.rawFrameDrawFailed"));
|
|
}
|
|
};
|
|
|
|
const drawFrame = async (frame: RdpFrame) => {
|
|
if (frame.type === "status" || frame.type === "error") {
|
|
setStatus(frame.message || "");
|
|
return;
|
|
}
|
|
if (frame.type === "session") {
|
|
setConnected(true);
|
|
if (frame.width && frame.height)
|
|
setCanvasSize({ width: frame.width, height: frame.height });
|
|
return;
|
|
}
|
|
if (frame.type === "clipboard") {
|
|
if (!clipboardEnabled) return;
|
|
if (frame.text) {
|
|
setRemoteClipboard(frame.text);
|
|
await copyToClipboard(frame.text);
|
|
}
|
|
return;
|
|
}
|
|
if (frame.type === "rdp_file_download") {
|
|
if (!fileDownloadEnabled) {
|
|
setStatus(t("server.sessionPolicy.fileDownloadDisabled"));
|
|
return;
|
|
}
|
|
if (frame.url) {
|
|
const link = document.createElement("a");
|
|
link.href = frame.url;
|
|
link.download = frame.name || "";
|
|
link.style.display = "none";
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
link.remove();
|
|
setStatus(
|
|
t("server.rdpDialog.downloadStarted", { name: frame.name || "file" }),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
if (frame.type !== "bitmap" || !frame.image) return;
|
|
setStatus("");
|
|
const canvas = canvasRef.current;
|
|
const ctx = canvas?.getContext("2d");
|
|
if (!canvas || !ctx) return;
|
|
ctx.imageSmoothingEnabled = false;
|
|
const received = statsRef.current.received + 1;
|
|
patchStats({ received });
|
|
try {
|
|
await paintBitmap(ctx, frame);
|
|
setHasFrame(true);
|
|
patchStats({ drawn: statsRef.current.drawn + 1 });
|
|
} catch {
|
|
patchStats({ failed: statsRef.current.failed + 1 });
|
|
setStatus(t("server.rdpDialog.bitmapDrawFailed", { count: received }));
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!open || !serverId) return;
|
|
|
|
const token =
|
|
useUserStore.getState().token || localStorage.getItem("token") || "";
|
|
const { width, height } = calcRdpViewport(fullscreen);
|
|
// 最大化时按浏览器可用空间重建 RDP 会话,避免把低分辨率画布拉伸后变糊。 // Logan.Li
|
|
setDisplaySize({ width, height });
|
|
setCanvasSize({ width, height });
|
|
setStatus(t("server.rdpDialog.creating"));
|
|
setHasFrame(false);
|
|
setRemoteClipboard("");
|
|
statsRef.current = { received: 0, drawn: 0, failed: 0 };
|
|
const canvas = canvasRef.current;
|
|
const ctx = canvas?.getContext("2d");
|
|
if (canvas && ctx) {
|
|
ctx.imageSmoothingEnabled = false;
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
ctx.fillStyle = "#000";
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
}
|
|
|
|
const ws = new WebSocket(
|
|
`${getWsBase()}/ws/rdp?id=${serverId}&width=${width}&height=${height}&token=${encodeURIComponent(token)}`,
|
|
);
|
|
ws.binaryType = "arraybuffer";
|
|
wsRef.current = ws;
|
|
|
|
ws.onopen = () => {
|
|
if (wsRef.current === ws) setConnected(true);
|
|
};
|
|
ws.onmessage = (event) => {
|
|
if (wsRef.current !== ws) return;
|
|
if (typeof event.data !== "string") {
|
|
paintQueueRef.current = paintQueueRef.current
|
|
.catch(() => undefined)
|
|
.then(() => drawRawFrame(event.data as Blob | ArrayBuffer));
|
|
return;
|
|
}
|
|
try {
|
|
const frame = JSON.parse(event.data) as RdpFrame;
|
|
paintQueueRef.current = paintQueueRef.current
|
|
.catch(() => undefined)
|
|
.then(() => drawFrame(frame));
|
|
} catch {
|
|
setStatus(String(event.data || t("server.rdpDialog.unparseableFrame")));
|
|
}
|
|
};
|
|
ws.onerror = () => {
|
|
if (wsRef.current === ws)
|
|
setStatus(t("server.rdpDialog.connectionError"));
|
|
};
|
|
ws.onclose = () => {
|
|
// 忽略切换分辨率时旧连接延迟到达的关闭事件。 // Logan.Li
|
|
if (wsRef.current !== ws) return;
|
|
setConnected(false);
|
|
setStatus((prev) => prev || t("server.rdpDialog.connectionClosed"));
|
|
};
|
|
|
|
return () => {
|
|
flushMouseMove();
|
|
ws.close();
|
|
if (wsRef.current === ws) wsRef.current = null;
|
|
paintQueueRef.current = Promise.resolve();
|
|
setConnected(false);
|
|
setToolsOpen(false);
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [open, serverId, fullscreen]);
|
|
|
|
const canvasPoint = (event: React.MouseEvent<HTMLCanvasElement>) => {
|
|
const rect = event.currentTarget.getBoundingClientRect();
|
|
const x = Math.round(
|
|
(event.clientX - rect.left) * (canvasSize.width / rect.width),
|
|
);
|
|
const y = Math.round(
|
|
(event.clientY - rect.top) * (canvasSize.height / rect.height),
|
|
);
|
|
return { x, y };
|
|
};
|
|
|
|
// 状态文本匹配错误关键词(中英双语,兼容 i18n 切换) // Logan.Li
|
|
const showConnectionIssue = Boolean(
|
|
status && /失败|错误|关闭|无法解析|fail|error|clos|unpars/i.test(status),
|
|
);
|
|
const showCenterLoading = open && !hasFrame && !showConnectionIssue;
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent
|
|
showCloseButton={false}
|
|
className={cn(
|
|
"bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0 overflow-hidden",
|
|
fullscreen
|
|
? "!max-w-none !w-screen !h-screen !rounded-none !translate-x-0 !translate-y-0 !left-0 !top-0"
|
|
: "",
|
|
)}
|
|
style={
|
|
fullscreen
|
|
? { maxWidth: "100vw", width: "100vw", height: "100vh" }
|
|
: { maxWidth: displaySize.width, width: displaySize.width }
|
|
}
|
|
>
|
|
<DialogTitle className="sr-only">RDP - {serverName}</DialogTitle>
|
|
<DialogDescription className="sr-only">
|
|
{t("server.rdpDialog.graphicSession")}
|
|
</DialogDescription>
|
|
<RdpDialogHeader
|
|
serverName={serverName}
|
|
serverIp={serverIp}
|
|
connected={connected}
|
|
fullscreen={fullscreen}
|
|
toolsOpen={toolsOpen}
|
|
onToolsOpenChange={setToolsOpen}
|
|
onToggleFullscreen={() => setFullscreen((current) => !current)}
|
|
onClose={() => onOpenChange(false)}
|
|
remoteClipboard={remoteClipboard}
|
|
onSyncToRemote={(text) => {
|
|
sendEvent({ type: "clipboard", text });
|
|
setStatus(t("server.rdpDialog.synced"));
|
|
}}
|
|
onRequestRemoteCopy={requestRemoteCopy}
|
|
onUploadVirtualFile={(name, text) => {
|
|
const sent = sendEvent({
|
|
type: "rdp_file_upload",
|
|
name,
|
|
text,
|
|
});
|
|
if (!sent) return false;
|
|
setStatus(t("server.rdpDialog.uploadStaged", { name }));
|
|
return true;
|
|
}}
|
|
/>
|
|
<div
|
|
className="relative bg-black flex items-center justify-center"
|
|
style={{
|
|
height: fullscreen
|
|
? `calc(100vh - ${RDP_HEADER_HEIGHT}px)`
|
|
: displaySize.height,
|
|
}}
|
|
>
|
|
<canvas
|
|
ref={canvasRef}
|
|
width={canvasSize.width}
|
|
height={canvasSize.height}
|
|
tabIndex={0}
|
|
className="max-w-full max-h-full outline-none"
|
|
style={{
|
|
// 会话分辨率与展示区域保持一致,避免浏览器二次放大画面。 // Logan.Li
|
|
width: fullscreen ? "100%" : displaySize.width,
|
|
height: fullscreen ? "100%" : displaySize.height,
|
|
}}
|
|
onMouseMove={(e) =>
|
|
sendMouseMove({ type: "mouse_move", ...canvasPoint(e) })
|
|
}
|
|
onMouseDown={(e) => {
|
|
e.preventDefault();
|
|
e.currentTarget.focus();
|
|
flushMouseMove();
|
|
sendEvent({
|
|
type: "mouse_down",
|
|
button: e.button,
|
|
...canvasPoint(e),
|
|
});
|
|
}}
|
|
onMouseUp={(e) => {
|
|
e.preventDefault();
|
|
flushMouseMove();
|
|
sendEvent({
|
|
type: "mouse_up",
|
|
button: e.button,
|
|
...canvasPoint(e),
|
|
});
|
|
}}
|
|
onWheel={(e) => {
|
|
flushMouseMove();
|
|
sendEvent({
|
|
type: "wheel",
|
|
delta_y: Math.trunc(e.deltaY),
|
|
...canvasPoint(e),
|
|
});
|
|
}}
|
|
onContextMenu={(e) => e.preventDefault()}
|
|
onPaste={(e) => {
|
|
if (!clipboardEnabled) {
|
|
setStatus(t("server.sessionPolicy.clipboardDisabled"));
|
|
return;
|
|
}
|
|
const text = e.clipboardData.getData("text/plain");
|
|
if (text) sendEvent({ type: "clipboard", text });
|
|
}}
|
|
onKeyDown={handleKeyDown}
|
|
onKeyUp={handleKeyUp}
|
|
/>
|
|
<SessionWatermark />
|
|
{showCenterLoading && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-black/25">
|
|
<Loader2 className="h-8 w-8 animate-spin text-white/80" />
|
|
</div>
|
|
)}
|
|
{showConnectionIssue && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-black/35 px-6 text-center text-sm text-white/85">
|
|
{status}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|