import { useEffect, useRef, useState, type KeyboardEvent, type MouseEvent } from 'react' import { useTranslation } from 'react-i18next' import { Cast, ClipboardPaste, FolderInput, FolderOpen, Monitor, RefreshCw, Unplug, } from 'lucide-react' import { useTabStore } from '../../stores/tabStore' import { useSessionStore } from '../../stores/sessionStore' import type { DesktopFrameEvent, DesktopPointerButton, DesktopProtocol, RdpConfig, VncConfig, } from '../../../shared/types/desktop.types' import { onRdpClipboard, onRdpFileDownload, onRdpFrame, onRdpStatus, rdpClipboardSet, rdpConnect, rdpDisconnect, rdpInputKey, rdpInputPointer, rdpUploadFile, } from '../../platform/rdp' import { onVncClipboard, onVncFrame, onVncStatus, vncClipboardSet, vncConnect, vncDisconnect, vncInputKey, vncInputPointer, } from '../../platform/vnc' import { sessionToRdpConfig, sessionToVncConfig } from '../../utils/sessionProtocol' interface DesktopSessionViewProps { tabId: string isActive: boolean } function decodeBase64Bytes(base64: string): Uint8Array { const binary = atob(base64) const buffer = new ArrayBuffer(binary.length) const bytes = new Uint8Array(buffer) const len = binary.length let i = 0 for (; i + 4 <= len; i += 4) { bytes[i] = binary.charCodeAt(i) bytes[i + 1] = binary.charCodeAt(i + 1) bytes[i + 2] = binary.charCodeAt(i + 2) bytes[i + 3] = binary.charCodeAt(i + 3) } for (; i < len; i++) bytes[i] = binary.charCodeAt(i) return bytes } function decodeRgbaBase64(base64: string): Uint8ClampedArray { return new Uint8ClampedArray(decodeBase64Bytes(base64).buffer) } export default function DesktopSessionView({ tabId, isActive }: DesktopSessionViewProps) { const { t } = useTranslation('desktop') const canvasRef = useRef(null) const canvasCtxRef = useRef(null) const nativeSessionIdRef = useRef(null) const isActiveRef = useRef(isActive) const fileInputRef = useRef(null) const buttonsRef = useRef({ left: false, middle: false, right: false }) const pointerRafRef = useRef(null) const paintRafRef = useRef(null) const pendingFramesRef = useRef([]) const pendingPointerRef = useRef<{ x: number y: number button?: DesktopPointerButton pressed?: boolean } | null>(null) const tab = useTabStore((state) => state.tabs.find((item) => item.id === tabId)) const sessions = useSessionStore((state) => state.sessions) const updateTabStatus = useTabStore((state) => state.updateTabStatus) const [statusMessage, setStatusMessage] = useState(() => t('status.connecting')) const [error, setError] = useState(null) const [fbSize, setFbSize] = useState({ width: 1920, height: 1080 }) const [resolvedConfig, setResolvedConfig] = useState(null) const protocol: DesktopProtocol = tab?.type === 'vnc' ? 'vnc' : 'rdp' isActiveRef.current = isActive useEffect(() => { let cancelled = false const resolve = async () => { if (protocol === 'rdp' && tab?.rdpConfig) { if (!cancelled) setResolvedConfig(tab.rdpConfig) return } if (protocol === 'vnc' && tab?.vncConfig) { if (!cancelled) setResolvedConfig(tab.vncConfig) return } if (tab?.sessionId) { const local = sessions.find((item) => item.id === tab.sessionId) if (local) { if (!cancelled) { setResolvedConfig( protocol === 'rdp' ? sessionToRdpConfig(local) : sessionToVncConfig(local) ) } return } const response = await window.opsxApi?.sessionGetById?.(tab.sessionId) if (!cancelled && response?.success && response.session) { setResolvedConfig( protocol === 'rdp' ? sessionToRdpConfig(response.session) : sessionToVncConfig(response.session) ) } } } void resolve() return () => { cancelled = true } }, [protocol, sessions, tab?.rdpConfig, tab?.sessionId, tab?.vncConfig]) useEffect(() => { if (!resolvedConfig) return let disposed = false let unlistenStatus: (() => void) | undefined let unlistenFrame: (() => void) | undefined let unlistenClipboard: (() => void) | undefined let unlistenFile: (() => void) | undefined const ensureCanvas = (fbWidth: number, fbHeight: number) => { const canvas = canvasRef.current if (!canvas) return null if (canvas.width !== fbWidth || canvas.height !== fbHeight) { canvas.width = fbWidth canvas.height = fbHeight canvasCtxRef.current = null setFbSize({ width: fbWidth, height: fbHeight }) } if (!canvasCtxRef.current) { canvasCtxRef.current = canvas.getContext('2d', { alpha: false }) } return canvasCtxRef.current } const paintOne = async (payload: DesktopFrameEvent) => { const ctx = ensureCanvas(payload.fbWidth, payload.fbHeight) if (!ctx) return if (payload.encoding === 'jpeg') { const bytes = decodeBase64Bytes(payload.dataBase64) const blob = new Blob([bytes], { type: 'image/jpeg' }) const bitmap = await createImageBitmap(blob) if (disposed) { bitmap.close() return } ctx.drawImage(bitmap, payload.x, payload.y) bitmap.close() return } const rgba = decodeRgbaBase64(payload.dataBase64) const image = new ImageData(rgba, payload.width, payload.height) ctx.putImageData(image, payload.x, payload.y) } const flushPendingFrames = () => { paintRafRef.current = null const batch = pendingFramesRef.current pendingFramesRef.current = [] if (!batch.length || !isActiveRef.current) return void (async () => { for (const frame of batch) { if (disposed) return try { await paintOne(frame) } catch { // Ignore a single bad frame; keep painting the rest. } } })() } const queueFrame = (payload: DesktopFrameEvent) => { if (!isActiveRef.current) return pendingFramesRef.current.push(payload) if (paintRafRef.current == null) { paintRafRef.current = window.requestAnimationFrame(flushPendingFrames) } } updateTabStatus(tabId, 'connecting') setStatusMessage(t('status.connecting')) setError(null) void (async () => { try { if (protocol === 'rdp') { unlistenStatus = await onRdpStatus((payload) => { if (nativeSessionIdRef.current && payload.sessionId !== nativeSessionIdRef.current) return if (!nativeSessionIdRef.current) nativeSessionIdRef.current = payload.sessionId if (payload.status === 'connected') { updateTabStatus(tabId, 'connected') setStatusMessage(payload.message || t('status.connected')) } else if (payload.status === 'error') { updateTabStatus(tabId, 'error') setError(payload.message || t('status.connectFailed')) } else if (payload.status === 'disconnected') { updateTabStatus(tabId, 'disconnected') setStatusMessage(t('status.disconnected')) } else if (payload.message) { setStatusMessage(payload.message) } }) unlistenFrame = await onRdpFrame((payload) => { if (payload.sessionId !== nativeSessionIdRef.current) return queueFrame(payload) }) unlistenClipboard = await onRdpClipboard((payload) => { if (payload.sessionId !== nativeSessionIdRef.current) return if (payload.text) { void navigator.clipboard.writeText(payload.text).catch(() => undefined) setStatusMessage(t('status.clipboardSynced')) } }) unlistenFile = await onRdpFileDownload((payload) => { if (payload.sessionId !== nativeSessionIdRef.current) return setStatusMessage(t('status.savedToDownloads', { name: payload.name })) }) const sessionId = await rdpConnect(resolvedConfig as RdpConfig) if (disposed) { await rdpDisconnect(sessionId) return } nativeSessionIdRef.current = sessionId } else { unlistenStatus = await onVncStatus((payload) => { if (nativeSessionIdRef.current && payload.sessionId !== nativeSessionIdRef.current) return if (!nativeSessionIdRef.current) nativeSessionIdRef.current = payload.sessionId if (payload.status === 'connected') { updateTabStatus(tabId, 'connected') setStatusMessage(payload.message || t('status.connected')) } else if (payload.status === 'error') { updateTabStatus(tabId, 'error') setError(payload.message || t('status.connectFailed')) } else if (payload.status === 'disconnected') { updateTabStatus(tabId, 'disconnected') setStatusMessage(t('status.disconnected')) } else if (payload.message) { setStatusMessage(payload.message) } }) unlistenFrame = await onVncFrame((payload) => { if (payload.sessionId !== nativeSessionIdRef.current) return queueFrame(payload) }) unlistenClipboard = await onVncClipboard((payload) => { if (payload.sessionId !== nativeSessionIdRef.current) return if (payload.text) { void navigator.clipboard.writeText(payload.text).catch(() => undefined) setStatusMessage(t('status.clipboardSynced')) } }) const sessionId = await vncConnect(resolvedConfig as VncConfig) if (disposed) { await vncDisconnect(sessionId) return } nativeSessionIdRef.current = sessionId } } catch (reason) { const message = reason instanceof Error ? reason.message : String(reason) setError(message) updateTabStatus(tabId, 'error') } })() return () => { disposed = true if (paintRafRef.current != null) { window.cancelAnimationFrame(paintRafRef.current) paintRafRef.current = null } pendingFramesRef.current = [] unlistenStatus?.() unlistenFrame?.() unlistenClipboard?.() unlistenFile?.() const sessionId = nativeSessionIdRef.current nativeSessionIdRef.current = null if (sessionId) { if (protocol === 'rdp') void rdpDisconnect(sessionId) else void vncDisconnect(sessionId) } } }, [protocol, resolvedConfig, tabId, updateTabStatus, t]) useEffect(() => { if (!isActive) return canvasRef.current?.focus() }, [isActive]) const flushPointer = () => { pointerRafRef.current = null const sessionId = nativeSessionIdRef.current const payload = pendingPointerRef.current pendingPointerRef.current = null if (!sessionId || !payload) return if (protocol === 'rdp') void rdpInputPointer(sessionId, payload) else void vncInputPointer(sessionId, payload) } const mapPointer = (event: MouseEvent, pressed?: boolean) => { const canvas = canvasRef.current const sessionId = nativeSessionIdRef.current if (!canvas || !sessionId) return const rect = canvas.getBoundingClientRect() const scaleX = canvas.width / Math.max(rect.width, 1) const scaleY = canvas.height / Math.max(rect.height, 1) const x = Math.max(0, Math.min(canvas.width - 1, Math.round((event.clientX - rect.left) * scaleX))) const y = Math.max(0, Math.min(canvas.height - 1, Math.round((event.clientY - rect.top) * scaleY))) const isMove = pressed === undefined let button: DesktopPointerButton | undefined let pressedOut: boolean | undefined = pressed if (!isMove) { button = event.button === 1 ? 'middle' : event.button === 2 ? 'right' : 'left' if (button === 'left') buttonsRef.current.left = !!pressed if (button === 'middle') buttonsRef.current.middle = !!pressed if (button === 'right') buttonsRef.current.right = !!pressed } else if (buttonsRef.current.left) { button = 'left' pressedOut = true } else if (buttonsRef.current.right) { button = 'right' pressedOut = true } else if (buttonsRef.current.middle) { button = 'middle' pressedOut = true } const payload = { x, y, button, pressed: pressedOut, moving: isMove } if (!isMove) { // Position first, then button — improves Windows hit-testing reliability. if (protocol === 'rdp') { void rdpInputPointer(sessionId, { x, y, moving: true }) void rdpInputPointer(sessionId, payload) } else { void vncInputPointer(sessionId, payload) } return } pendingPointerRef.current = payload if (pointerRafRef.current == null) { pointerRafRef.current = window.requestAnimationFrame(flushPointer) } } const pasteToRemote = async () => { const sessionId = nativeSessionIdRef.current if (!sessionId) return const text = await navigator.clipboard.readText() if (!text) return if (protocol === 'rdp') await rdpClipboardSet(sessionId, text) else await vncClipboardSet(sessionId, text) setStatusMessage(t('status.pastedToRemote')) } const onKey = (event: KeyboardEvent, pressed: boolean) => { const sessionId = nativeSessionIdRef.current if (!sessionId) return event.preventDefault() const isPasteChord = pressed && (event.metaKey || event.ctrlKey) && event.code === 'KeyV' && !event.altKey && !event.shiftKey if (isPasteChord) { // Do not forward Ctrl/Cmd+V — sync remote clipboard then native injects Ctrl+V. void pasteToRemote() return } const payload = { key: event.key, code: event.code, pressed, altKey: event.altKey, ctrlKey: event.ctrlKey, metaKey: event.metaKey, shiftKey: event.shiftKey, } if (protocol === 'rdp') void rdpInputKey(sessionId, payload) else void vncInputKey(sessionId, payload) } const onShareFileSelected = async (fileList: FileList | null) => { const sessionId = nativeSessionIdRef.current const file = fileList?.[0] if (!sessionId || !file || protocol !== 'rdp') return if (file.size > 50 * 1024 * 1024) { setError(t('status.fileTooLarge')) return } const buffer = new Uint8Array(await file.arrayBuffer()) let binary = '' for (let i = 0; i < buffer.length; i++) binary += String.fromCharCode(buffer[i]) await rdpUploadFile(sessionId, file.name, btoa(binary)) setStatusMessage(t('status.fileShared', { name: file.name })) } const openSharedFolder = async () => { try { await window.opsxApi?.openDownloadsFolder?.() } catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)) } } const disconnectSession = async () => { const sessionId = nativeSessionIdRef.current if (!sessionId) return try { if (protocol === 'rdp') await rdpDisconnect(sessionId) else await vncDisconnect(sessionId) nativeSessionIdRef.current = null updateTabStatus(tabId, 'disconnected') setStatusMessage(t('status.disconnected')) } catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)) } } const Icon = protocol === 'rdp' ? Monitor : Cast const hostLabel = resolvedConfig && 'host' in resolvedConfig ? resolvedConfig.host : tab?.title const connected = tab?.connectionStatus === 'connected' const toolBtn = 'inline-flex h-7 w-7 items-center justify-center rounded-md text-content-secondary transition-colors hover:bg-dark-700 hover:text-content-primary disabled:cursor-not-allowed disabled:opacity-40' return (
{hostLabel} {fbSize.width}×{fbSize.height} {tab?.connectionStatus === 'connecting' ? ( ) : ( )} {statusMessage} {error && {error}}
{protocol === 'rdp' && ( <> { void onShareFileSelected(event.target.files) event.target.value = '' }} /> )}
event.preventDefault()} onMouseMove={(event) => mapPointer(event)} onMouseDown={(event) => mapPointer(event, true)} onMouseUp={(event) => mapPointer(event, false)} onMouseLeave={() => { buttonsRef.current = { left: false, middle: false, right: false } }} onKeyDown={(event) => onKey(event, true)} onKeyUp={(event) => onKey(event, false)} />
) }