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(null); const wsRef = useRef(null); const paintQueueRef = useRef>(Promise.resolve()); const statsRef = useRef({ received: 0, drawn: 0, failed: 0 }); const pendingMouseMoveRef = useRef | null>(null); const mouseMoveTimerRef = useRef(null); const keyModifierRef = useRef({ 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) => { 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) => { 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, 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) => { 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) => { 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) => { 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) => { 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 ( RDP - {serverName} {t("server.rdpDialog.graphicSession")} 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; }} />
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} /> {showCenterLoading && (
)} {showConnectionIssue && (
{status}
)}
); }