- Extend AppLanguage to 7 locales: zh-CN, zh-TW, en, ja, ko, vi, ru - Auto-detect system language via navigator.languages; fallback to English - Add BCP47 locale mapping for date formatting and sorting - Create 50 translation JSON files (5 languages × 10 namespaces, 1137 keys each) - Update language selector with native language names - Extend locale key parity test to cover all 7 languages (70 assertions) - Add VNC/RDP desktop, serial terminal, AI agent, port forwarding, and SFTP features - Add credential encryption, host metrics, session backup crypto, and zmodem bridge - Update Rust backend: desktop services, host metrics, SSH/SFTP enhancements
560 lines
20 KiB
TypeScript
560 lines
20 KiB
TypeScript
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<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 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 pendingFramesRef = useRef<DesktopFrameEvent[]>([])
|
||
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<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 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<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 () => {
|
||
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<HTMLCanvasElement>, 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 (
|
||
<div className="h-full flex flex-col bg-dark-900">
|
||
<div className="flex items-center gap-2 border-b border-dark-700/50 bg-dark-800/80 px-2.5 py-1.5 text-xs">
|
||
<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" />
|
||
) : (
|
||
<span
|
||
className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${
|
||
connected ? 'bg-green-500' : tab?.connectionStatus === 'error' ? 'bg-red-500' : 'bg-dark-500'
|
||
}`}
|
||
/>
|
||
)}
|
||
<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>}
|
||
|
||
<div className="ml-1 flex flex-shrink-0 items-center gap-0.5">
|
||
<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} text-red-400 hover:bg-red-500/15 hover:text-red-300`}
|
||
disabled={!connected}
|
||
onClick={() => void disconnectSession()}
|
||
title={t('toolbar.disconnect')}
|
||
aria-label={t('toolbar.disconnectAria')}
|
||
>
|
||
<Unplug className="h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex min-h-0 flex-1 items-center justify-center overflow-auto bg-black p-2">
|
||
<canvas
|
||
ref={canvasRef}
|
||
tabIndex={0}
|
||
className="max-h-full max-w-full cursor-default outline-none 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 }
|
||
}}
|
||
onKeyDown={(event) => onKey(event, true)}
|
||
onKeyUp={(event) => onKey(event, false)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|