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
This commit is contained in:
Logan.Li committed 2026-08-12 00:07:48 +08:00
1 parent fea6d8db04
commit b0d0e3a5f3
301 files changed
+28002 -2705

No files matched your search

@@ -0,0 +1,559 @@
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>
)
}