Files
opsxshell/src/components/desktop/DesktopSessionView.tsx
T
Logan.Li b0d0e3a5f3 feat(i18n): add multi-language support (ja/ko/vi/ru/zh-TW) with system auto-detection
- 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
2026-08-12 00:07:48 +08:00

560 lines
20 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 { 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>
)
}