873 lines
32 KiB
TypeScript
873 lines
32 KiB
TypeScript
import {
|
||
useCallback,
|
||
useEffect,
|
||
useRef,
|
||
useState,
|
||
type ClipboardEvent,
|
||
type KeyboardEvent,
|
||
type MouseEvent,
|
||
} from 'react'
|
||
import { useTranslation } from 'react-i18next'
|
||
import { Cast, ClipboardPaste, FolderInput, FolderOpen, Maximize2, Minimize2, Monitor, RefreshCw } from 'lucide-react'
|
||
import { useTabStore } from '../../stores/tabStore'
|
||
import { useSessionStore } from '../../stores/sessionStore'
|
||
import { useSettingsStore } from '../../stores/settingsStore'
|
||
import { useDesktopFullscreenStore } from '../../stores/desktopFullscreenStore'
|
||
import StatusDot from '../common/StatusDot'
|
||
import type {
|
||
DesktopFrameEvent,
|
||
DesktopPointerButton,
|
||
DesktopProtocol,
|
||
RdpConfig,
|
||
VncConfig,
|
||
} from '../../../shared/types/desktop.types'
|
||
import {
|
||
onRdpClipboard,
|
||
onRdpFileDownload,
|
||
onRdpFrame,
|
||
onRdpStatus,
|
||
rdpClipboardSet,
|
||
rdpConnect,
|
||
rdpDisconnect,
|
||
rdpInputKey,
|
||
rdpInputPointer,
|
||
rdpResize,
|
||
rdpUploadFile,
|
||
} from '../../platform/rdp'
|
||
import {
|
||
onVncClipboard,
|
||
onVncFrame,
|
||
onVncStatus,
|
||
vncClipboardSet,
|
||
vncConnect,
|
||
vncDisconnect,
|
||
vncInputKey,
|
||
vncInputPointer,
|
||
} from '../../platform/vnc'
|
||
import { readClipboardText, writeClipboardText } from '../../platform/clipboard'
|
||
import { sessionToRdpConfig, sessionToVncConfig } from '../../utils/sessionProtocol'
|
||
import {
|
||
clampDesktopResolution,
|
||
desktopCanvasDisplaySize,
|
||
isUsableDesktopPane,
|
||
resolutionFromPane,
|
||
type DesktopResolution,
|
||
} from '../../utils/desktopResolution'
|
||
import { reconnectDelayMs } from '../../utils/disconnectReason'
|
||
import { subscribeTabReconnect } from '../../utils/tabReconnect'
|
||
|
||
interface DesktopSessionViewProps {
|
||
tabId: string
|
||
isActive: boolean
|
||
}
|
||
|
||
function decodeBase64Bytes(base64: string): Uint8Array<ArrayBuffer> {
|
||
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<ArrayBuffer> {
|
||
return new Uint8ClampedArray(decodeBase64Bytes(base64).buffer)
|
||
}
|
||
|
||
export default function DesktopSessionView({ tabId, isActive }: DesktopSessionViewProps) {
|
||
const { t } = useTranslation('desktop')
|
||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||
const paneRef = useRef<HTMLDivElement>(null)
|
||
const canvasCtxRef = useRef<CanvasRenderingContext2D | null>(null)
|
||
const nativeSessionIdRef = useRef<string | null>(null)
|
||
const isActiveRef = useRef(isActive)
|
||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||
const buttonsRef = useRef({ left: false, middle: false, right: false })
|
||
const pointerRafRef = useRef<number | null>(null)
|
||
const paintRafRef = useRef<number | null>(null)
|
||
const hideBarTimerRef = useRef<number | null>(null)
|
||
const pendingFramesRef = useRef<DesktopFrameEvent[]>([])
|
||
const lastRemoteResizeRef = useRef<{ width: number; height: number } | null>(null)
|
||
const remoteResizeTimerRef = useRef<number | null>(null)
|
||
const connectedRef = useRef(false)
|
||
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 fullscreenActive = useDesktopFullscreenStore((state) => state.active && state.tabId === tabId)
|
||
const enterFullscreen = useDesktopFullscreenStore((state) => state.enter)
|
||
const exitFullscreen = useDesktopFullscreenStore((state) => state.exit)
|
||
const toggleFullscreen = useDesktopFullscreenStore((state) => state.toggle)
|
||
const [statusMessage, setStatusMessage] = useState<string>(() => t('status.connecting'))
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [fbSize, setFbSize] = useState({ width: 1920, height: 1080 })
|
||
const [resolvedConfig, setResolvedConfig] = useState<RdpConfig | VncConfig | null>(null)
|
||
const [chromeVisible, setChromeVisible] = useState(true)
|
||
const [reconnectGeneration, setReconnectGeneration] = useState(0)
|
||
const reconnectAttemptRef = useRef(0)
|
||
const reconnectTimerRef = useRef<number | null>(null)
|
||
const intentionalDisconnectRef = useRef(false)
|
||
const hadConnectedRef = useRef(false)
|
||
const pasteLockRef = useRef(0)
|
||
|
||
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])
|
||
|
||
const clearDesktopReconnectTimer = useCallback(() => {
|
||
if (reconnectTimerRef.current != null) {
|
||
window.clearTimeout(reconnectTimerRef.current)
|
||
reconnectTimerRef.current = null
|
||
}
|
||
}, [])
|
||
|
||
const scheduleDesktopReconnect = useCallback(() => {
|
||
const { settings } = useSettingsStore.getState()
|
||
if (!settings.autoReconnect) return
|
||
if (intentionalDisconnectRef.current) return
|
||
if (!hadConnectedRef.current) return
|
||
const maxAttempts = settings.maxReconnectAttempts ?? 5
|
||
const baseDelay = settings.reconnectBaseDelayMs ?? 1500
|
||
if (reconnectAttemptRef.current >= maxAttempts) {
|
||
setError(t('status.reconnectLimit', { max: maxAttempts }))
|
||
updateTabStatus(tabId, 'error')
|
||
return
|
||
}
|
||
reconnectAttemptRef.current += 1
|
||
const attempt = reconnectAttemptRef.current
|
||
const delay = reconnectDelayMs(attempt, baseDelay)
|
||
updateTabStatus(tabId, 'connecting')
|
||
setStatusMessage(t('status.reconnecting', { attempt, max: maxAttempts }))
|
||
clearDesktopReconnectTimer()
|
||
reconnectTimerRef.current = window.setTimeout(() => {
|
||
reconnectTimerRef.current = null
|
||
setReconnectGeneration((value) => value + 1)
|
||
}, delay)
|
||
}, [clearDesktopReconnectTimer, t, tabId, updateTabStatus])
|
||
|
||
const handleDesktopReconnect = useCallback(() => {
|
||
clearDesktopReconnectTimer()
|
||
reconnectAttemptRef.current = 0
|
||
intentionalDisconnectRef.current = false
|
||
setError(null)
|
||
setReconnectGeneration((value) => value + 1)
|
||
}, [clearDesktopReconnectTimer])
|
||
|
||
useEffect(() => {
|
||
return () => clearDesktopReconnectTimer()
|
||
}, [clearDesktopReconnectTimer])
|
||
|
||
useEffect(() => subscribeTabReconnect(tabId, handleDesktopReconnect), [tabId, handleDesktopReconnect])
|
||
|
||
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)
|
||
connectedRef.current = false
|
||
lastRemoteResizeRef.current = null
|
||
|
||
const waitForPaneResolution = async (fallback: DesktopResolution): Promise<DesktopResolution> => {
|
||
for (let i = 0; i < 24; i++) {
|
||
const pane = paneRef.current
|
||
const width = pane?.clientWidth ?? 0
|
||
const height = pane?.clientHeight ?? 0
|
||
if (pane && isUsableDesktopPane(width, height)) {
|
||
return resolutionFromPane(width, height, fallback)
|
||
}
|
||
await new Promise<void>((resolve) => {
|
||
window.requestAnimationFrame(() => resolve())
|
||
})
|
||
}
|
||
const pane = paneRef.current
|
||
return resolutionFromPane(pane?.clientWidth ?? 0, pane?.clientHeight ?? 0, fallback)
|
||
}
|
||
|
||
void (async () => {
|
||
try {
|
||
if (protocol === 'rdp') {
|
||
unlistenStatus = await onRdpStatus((payload) => {
|
||
if (nativeSessionIdRef.current && payload.sessionId !== nativeSessionIdRef.current) return
|
||
if (!nativeSessionIdRef.current) {
|
||
if (payload.status === 'disconnected' || payload.status === 'error') return
|
||
nativeSessionIdRef.current = payload.sessionId
|
||
}
|
||
if (payload.width && payload.height) {
|
||
setFbSize({ width: payload.width, height: payload.height })
|
||
lastRemoteResizeRef.current = { width: payload.width, height: payload.height }
|
||
}
|
||
if (payload.status === 'connected') {
|
||
connectedRef.current = true
|
||
hadConnectedRef.current = true
|
||
reconnectAttemptRef.current = 0
|
||
updateTabStatus(tabId, 'connected')
|
||
setStatusMessage(payload.message || t('status.connected'))
|
||
} else if (payload.status === 'error') {
|
||
connectedRef.current = false
|
||
updateTabStatus(tabId, 'error')
|
||
setError(payload.message || t('status.connectFailed'))
|
||
if (!disposed) scheduleDesktopReconnect()
|
||
} else if (payload.status === 'disconnected') {
|
||
connectedRef.current = false
|
||
updateTabStatus(tabId, 'disconnected')
|
||
setStatusMessage(t('status.disconnected'))
|
||
if (!disposed) scheduleDesktopReconnect()
|
||
} 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 writeClipboardText(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 rdpConfig = resolvedConfig as RdpConfig
|
||
const paneSize = await waitForPaneResolution(clampDesktopResolution(rdpConfig.width, rdpConfig.height))
|
||
if (disposed) return
|
||
lastRemoteResizeRef.current = paneSize
|
||
const sessionId = await rdpConnect({
|
||
...rdpConfig,
|
||
width: paneSize.width,
|
||
height: paneSize.height,
|
||
})
|
||
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) {
|
||
if (payload.status === 'disconnected' || payload.status === 'error') return
|
||
nativeSessionIdRef.current = payload.sessionId
|
||
}
|
||
if (payload.status === 'connected') {
|
||
connectedRef.current = true
|
||
hadConnectedRef.current = true
|
||
reconnectAttemptRef.current = 0
|
||
updateTabStatus(tabId, 'connected')
|
||
setStatusMessage(payload.message || t('status.connected'))
|
||
} else if (payload.status === 'error') {
|
||
connectedRef.current = false
|
||
updateTabStatus(tabId, 'error')
|
||
setError(payload.message || t('status.connectFailed'))
|
||
if (!disposed) scheduleDesktopReconnect()
|
||
} else if (payload.status === 'disconnected') {
|
||
connectedRef.current = false
|
||
updateTabStatus(tabId, 'disconnected')
|
||
setStatusMessage(t('status.disconnected'))
|
||
if (!disposed) scheduleDesktopReconnect()
|
||
} 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 writeClipboardText(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
|
||
connectedRef.current = false
|
||
if (remoteResizeTimerRef.current != null) {
|
||
window.clearTimeout(remoteResizeTimerRef.current)
|
||
remoteResizeTimerRef.current = null
|
||
}
|
||
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, reconnectGeneration, scheduleDesktopReconnect])
|
||
|
||
useEffect(() => {
|
||
if (!isActive) return
|
||
canvasRef.current?.focus()
|
||
}, [isActive])
|
||
|
||
// Fit canvas to pane; for RDP also request remote dynamic resolution (mstsc / FreeRDP style).
|
||
useEffect(() => {
|
||
const pane = paneRef.current
|
||
const canvas = canvasRef.current
|
||
if (!pane || !canvas || fbSize.width <= 0 || fbSize.height <= 0) return
|
||
|
||
const updateFit = () => {
|
||
const display = desktopCanvasDisplaySize(
|
||
protocol,
|
||
pane.clientWidth,
|
||
pane.clientHeight,
|
||
fbSize.width,
|
||
fbSize.height
|
||
)
|
||
canvas.style.width = `${display.width}px`
|
||
canvas.style.height = `${display.height}px`
|
||
}
|
||
|
||
const requestRemoteResize = () => {
|
||
if (protocol !== 'rdp' || !connectedRef.current || !isActive) return
|
||
const sessionId = nativeSessionIdRef.current
|
||
if (!sessionId) return
|
||
const next = clampDesktopResolution(pane.clientWidth, pane.clientHeight)
|
||
const last = lastRemoteResizeRef.current
|
||
if (last && last.width === next.width && last.height === next.height) return
|
||
// Skip tiny panes (drawer/animation) that would thrash the session.
|
||
if (next.width < 640 || next.height < 480) return
|
||
lastRemoteResizeRef.current = next
|
||
void rdpResize(sessionId, next.width, next.height).catch(() => {
|
||
// Allow retry on next size change if Display Control was not ready.
|
||
lastRemoteResizeRef.current = last
|
||
})
|
||
}
|
||
|
||
const onPaneResize = () => {
|
||
updateFit()
|
||
if (remoteResizeTimerRef.current != null) {
|
||
window.clearTimeout(remoteResizeTimerRef.current)
|
||
}
|
||
remoteResizeTimerRef.current = window.setTimeout(requestRemoteResize, 280)
|
||
}
|
||
|
||
updateFit()
|
||
// After fullscreen / maximize layout settles, request matching remote size once.
|
||
remoteResizeTimerRef.current = window.setTimeout(requestRemoteResize, fullscreenActive ? 120 : 280)
|
||
const observer = new ResizeObserver(onPaneResize)
|
||
observer.observe(pane)
|
||
return () => {
|
||
observer.disconnect()
|
||
if (remoteResizeTimerRef.current != null) {
|
||
window.clearTimeout(remoteResizeTimerRef.current)
|
||
remoteResizeTimerRef.current = null
|
||
}
|
||
}
|
||
}, [fbSize.height, fbSize.width, fullscreenActive, isActive, protocol])
|
||
|
||
// Leave OS fullscreen when this tab is hidden or unmounted.
|
||
useEffect(() => {
|
||
if (!isActive && fullscreenActive) {
|
||
void exitFullscreen()
|
||
}
|
||
}, [exitFullscreen, fullscreenActive, isActive])
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (hideBarTimerRef.current != null) {
|
||
window.clearTimeout(hideBarTimerRef.current)
|
||
}
|
||
// Only exit when this session view is truly torn down (tab closed), not on HMR-only paths.
|
||
const state = useDesktopFullscreenStore.getState()
|
||
if (state.active && state.tabId === tabId) {
|
||
const stillOpen = useTabStore.getState().tabs.some((item) => item.id === tabId)
|
||
if (!stillOpen) void state.exit()
|
||
}
|
||
}
|
||
}, [tabId])
|
||
|
||
useEffect(() => {
|
||
if (!fullscreenActive) {
|
||
setChromeVisible(true)
|
||
if (hideBarTimerRef.current != null) {
|
||
window.clearTimeout(hideBarTimerRef.current)
|
||
hideBarTimerRef.current = null
|
||
}
|
||
return
|
||
}
|
||
// If the user leaves OS fullscreen via system UI, drop session chrome-hide.
|
||
const timer = window.setInterval(() => {
|
||
void (async () => {
|
||
try {
|
||
const state = useDesktopFullscreenStore.getState()
|
||
if (!state.active || state.tabId !== tabId) return
|
||
if (Date.now() - state.enteredAt < 1600) return
|
||
const osFs = await window.opsxApi?.isFullscreen?.()
|
||
if (osFs === false) {
|
||
useDesktopFullscreenStore.setState({ active: false, tabId: null, enteredAt: 0 })
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
})()
|
||
}, 1000)
|
||
return () => window.clearInterval(timer)
|
||
}, [fullscreenActive, tabId])
|
||
|
||
const scheduleHideChrome = useCallback(() => {
|
||
if (!fullscreenActive) return
|
||
if (hideBarTimerRef.current != null) window.clearTimeout(hideBarTimerRef.current)
|
||
hideBarTimerRef.current = window.setTimeout(() => {
|
||
setChromeVisible(false)
|
||
hideBarTimerRef.current = null
|
||
}, 1800)
|
||
}, [fullscreenActive])
|
||
|
||
const revealChrome = useCallback(() => {
|
||
setChromeVisible(true)
|
||
scheduleHideChrome()
|
||
}, [scheduleHideChrome])
|
||
|
||
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<HTMLCanvasElement>, 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 (textFromEvent?: string) => {
|
||
const sessionId = nativeSessionIdRef.current
|
||
if (!sessionId) return
|
||
const now = Date.now()
|
||
if (now - pasteLockRef.current < 400) return
|
||
pasteLockRef.current = now
|
||
try {
|
||
const text = textFromEvent ?? (await readClipboardText())
|
||
if (!text) return
|
||
if (protocol === 'rdp') await rdpClipboardSet(sessionId, text)
|
||
else await vncClipboardSet(sessionId, text)
|
||
setStatusMessage(t('status.pastedToRemote'))
|
||
} catch (reason) {
|
||
setStatusMessage(reason instanceof Error ? reason.message : String(reason))
|
||
}
|
||
}
|
||
|
||
const onPasteClipboard = (event: ClipboardEvent<HTMLCanvasElement>) => {
|
||
event.preventDefault()
|
||
event.stopPropagation()
|
||
const text = event.clipboardData?.getData('text/plain')
|
||
void pasteToRemote(text || undefined)
|
||
}
|
||
|
||
const onKey = (event: KeyboardEvent<HTMLCanvasElement>, pressed: boolean) => {
|
||
const sessionId = nativeSessionIdRef.current
|
||
if (!sessionId) return
|
||
|
||
// Local fullscreen controls — never forward to the remote desktop.
|
||
if (event.key === 'F11') {
|
||
event.preventDefault()
|
||
event.stopPropagation()
|
||
if (pressed) void toggleFullscreen(tabId)
|
||
return
|
||
}
|
||
if (fullscreenActive && event.key === 'Escape') {
|
||
event.preventDefault()
|
||
event.stopPropagation()
|
||
if (pressed) void exitFullscreen()
|
||
return
|
||
}
|
||
|
||
event.preventDefault()
|
||
|
||
const isPasteChord = (event.metaKey || event.ctrlKey) && event.code === 'KeyV' && !event.altKey && !event.shiftKey
|
||
|
||
if (isPasteChord) {
|
||
// Swallow Cmd/Ctrl+V (keydown and keyup). Native VNC types the text as
|
||
// keysyms so remote terminals do not wrap it in bracketed paste.
|
||
if (pressed) 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 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'
|
||
|
||
const toolbar = (
|
||
<div
|
||
className={`flex items-center gap-2 px-2.5 py-1.5 text-xs ${
|
||
fullscreenActive
|
||
? 'border-dark-700/60 bg-dark-900/95 rounded-b-lg border border-t-0 shadow-lg shadow-black/40 backdrop-blur'
|
||
: 'border-dark-700/50 bg-dark-800/80 border-b'
|
||
}`}
|
||
onMouseEnter={() => {
|
||
if (fullscreenActive) {
|
||
setChromeVisible(true)
|
||
if (hideBarTimerRef.current != null) {
|
||
window.clearTimeout(hideBarTimerRef.current)
|
||
hideBarTimerRef.current = null
|
||
}
|
||
}
|
||
}}
|
||
onMouseLeave={() => {
|
||
if (fullscreenActive) scheduleHideChrome()
|
||
}}
|
||
>
|
||
<Icon className="h-3.5 w-3.5 flex-shrink-0 text-accent-blue" />
|
||
<span className="max-w-[10rem] truncate font-mono text-content-secondary" title={hostLabel}>
|
||
{hostLabel}
|
||
</span>
|
||
<span className="tabular-nums text-txt-faint">
|
||
{fbSize.width}×{fbSize.height}
|
||
</span>
|
||
{tab?.connectionStatus === 'connecting' ? (
|
||
<RefreshCw className="h-3 w-3 flex-shrink-0 animate-spin text-yellow-400" />
|
||
) : (
|
||
<StatusDot state={connected ? 'connected' : tab?.connectionStatus === 'error' ? 'error' : 'disconnected'} />
|
||
)}
|
||
<span className="min-w-0 flex-1 truncate text-txt-muted">{statusMessage}</span>
|
||
{error && <span className="max-w-[30%] truncate text-red-400">{error}</span>}
|
||
{fullscreenActive && (
|
||
<span className="hidden shrink-0 text-[10px] text-txt-faint sm:inline">{t('toolbar.exitFullscreenHint')}</span>
|
||
)}
|
||
|
||
<div className="ml-1 flex flex-shrink-0 items-center gap-0.5">
|
||
<button
|
||
type="button"
|
||
className={toolBtn}
|
||
disabled={connected || tab?.connectionStatus === 'connecting'}
|
||
onClick={() => handleDesktopReconnect()}
|
||
title={t('toolbar.reconnect')}
|
||
aria-label={t('toolbar.reconnectAria')}
|
||
>
|
||
<RefreshCw className="h-3.5 w-3.5" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={toolBtn}
|
||
disabled={!connected}
|
||
onClick={() => void pasteToRemote()}
|
||
title={t('toolbar.pasteClipboard')}
|
||
aria-label={t('toolbar.pasteAria')}
|
||
>
|
||
<ClipboardPaste className="h-3.5 w-3.5" />
|
||
</button>
|
||
|
||
{protocol === 'rdp' && (
|
||
<>
|
||
<button
|
||
type="button"
|
||
className={toolBtn}
|
||
disabled={!connected}
|
||
onClick={() => fileInputRef.current?.click()}
|
||
title={t('toolbar.shareDrive')}
|
||
aria-label={t('toolbar.shareDriveAria')}
|
||
>
|
||
<FolderInput className="h-3.5 w-3.5" />
|
||
</button>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
className="hidden"
|
||
onChange={(event) => {
|
||
void onShareFileSelected(event.target.files)
|
||
event.target.value = ''
|
||
}}
|
||
/>
|
||
<button
|
||
type="button"
|
||
className={toolBtn}
|
||
onClick={() => void openSharedFolder()}
|
||
title={t('toolbar.openShareDir')}
|
||
aria-label={t('toolbar.openShareDirAria')}
|
||
>
|
||
<FolderOpen className="h-3.5 w-3.5" />
|
||
</button>
|
||
</>
|
||
)}
|
||
|
||
<button
|
||
type="button"
|
||
className={toolBtn}
|
||
onClick={(event) => {
|
||
event.preventDefault()
|
||
event.stopPropagation()
|
||
void (fullscreenActive ? exitFullscreen() : enterFullscreen(tabId))
|
||
}}
|
||
title={fullscreenActive ? t('toolbar.exitFullscreen') : t('toolbar.fullscreen')}
|
||
aria-label={fullscreenActive ? t('toolbar.exitFullscreenAria') : t('toolbar.fullscreenAria')}
|
||
>
|
||
{fullscreenActive ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
|
||
return (
|
||
<div
|
||
className={`relative flex h-full flex-col bg-dark-900 ${fullscreenActive ? 'bg-black' : ''}`}
|
||
onMouseMove={(event) => {
|
||
if (!fullscreenActive) return
|
||
if (event.clientY <= 56) revealChrome()
|
||
}}
|
||
>
|
||
{fullscreenActive ? (
|
||
<div
|
||
className={`pointer-events-none absolute inset-x-0 top-0 z-20 flex justify-center px-3 transition-opacity duration-200 ${
|
||
chromeVisible ? 'opacity-100' : 'opacity-0'
|
||
}`}
|
||
>
|
||
<div className="pointer-events-auto w-full max-w-5xl">{toolbar}</div>
|
||
</div>
|
||
) : (
|
||
toolbar
|
||
)}
|
||
|
||
<div
|
||
ref={paneRef}
|
||
className={`flex min-h-0 flex-1 overflow-hidden bg-black ${
|
||
protocol === 'rdp' || fullscreenActive
|
||
? 'items-stretch justify-stretch p-0'
|
||
: 'items-center justify-center p-2'
|
||
}`}
|
||
>
|
||
<canvas
|
||
ref={canvasRef}
|
||
tabIndex={0}
|
||
className={`cursor-default outline-none ${
|
||
protocol === 'rdp' ? 'h-full w-full' : 'shadow-lg shadow-black/40'
|
||
}`}
|
||
onContextMenu={(event) => 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 }
|
||
}}
|
||
onPaste={onPasteClipboard}
|
||
onKeyDown={(event) => onKey(event, true)}
|
||
onKeyUp={(event) => onKey(event, false)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|