Files
opsxshell/src/components/desktop/DesktopSessionView.tsx
T

873 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}