chore: 保存工作副本——客户端授权新契约(双端点/验签/令牌/周检)与桌面会话可靠性改进
This commit is contained in:
1 parent
f71eef0eba
commit
ae6093c9b3
163 files changed
+4911
-2494
No files matched your search
@@ -1,8 +1,17 @@
|
||||
import { useCallback, useEffect, useRef, useState, type KeyboardEvent, type MouseEvent } from 'react'
|
||||
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 {
|
||||
@@ -35,8 +44,17 @@ import {
|
||||
vncInputKey,
|
||||
vncInputPointer,
|
||||
} from '../../platform/vnc'
|
||||
import { readClipboardText, writeClipboardText } from '../../platform/clipboard'
|
||||
import { sessionToRdpConfig, sessionToVncConfig } from '../../utils/sessionProtocol'
|
||||
import { clampDesktopResolution } from '../../utils/desktopResolution'
|
||||
import {
|
||||
clampDesktopResolution,
|
||||
desktopCanvasDisplaySize,
|
||||
isUsableDesktopPane,
|
||||
resolutionFromPane,
|
||||
type DesktopResolution,
|
||||
} from '../../utils/desktopResolution'
|
||||
import { reconnectDelayMs } from '../../utils/disconnectReason'
|
||||
import { subscribeTabReconnect } from '../../utils/tabReconnect'
|
||||
|
||||
interface DesktopSessionViewProps {
|
||||
tabId: string
|
||||
@@ -97,6 +115,12 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
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
|
||||
@@ -134,6 +158,51 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
}
|
||||
}, [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
|
||||
@@ -208,28 +277,51 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
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) nativeSessionIdRef.current = payload.sessionId
|
||||
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)
|
||||
}
|
||||
@@ -241,7 +333,7 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
unlistenClipboard = await onRdpClipboard((payload) => {
|
||||
if (payload.sessionId !== nativeSessionIdRef.current) return
|
||||
if (payload.text) {
|
||||
void navigator.clipboard.writeText(payload.text).catch(() => undefined)
|
||||
void writeClipboardText(payload.text).catch(() => undefined)
|
||||
setStatusMessage(t('status.clipboardSynced'))
|
||||
}
|
||||
})
|
||||
@@ -249,7 +341,15 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
if (payload.sessionId !== nativeSessionIdRef.current) return
|
||||
setStatusMessage(t('status.savedToDownloads', { name: payload.name }))
|
||||
})
|
||||
const sessionId = await rdpConnect(resolvedConfig as RdpConfig)
|
||||
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
|
||||
@@ -258,16 +358,26 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
} else {
|
||||
unlistenStatus = await onVncStatus((payload) => {
|
||||
if (nativeSessionIdRef.current && payload.sessionId !== nativeSessionIdRef.current) return
|
||||
if (!nativeSessionIdRef.current) nativeSessionIdRef.current = payload.sessionId
|
||||
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)
|
||||
}
|
||||
@@ -279,7 +389,7 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
unlistenClipboard = await onVncClipboard((payload) => {
|
||||
if (payload.sessionId !== nativeSessionIdRef.current) return
|
||||
if (payload.text) {
|
||||
void navigator.clipboard.writeText(payload.text).catch(() => undefined)
|
||||
void writeClipboardText(payload.text).catch(() => undefined)
|
||||
setStatusMessage(t('status.clipboardSynced'))
|
||||
}
|
||||
})
|
||||
@@ -320,7 +430,7 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
else void vncDisconnect(sessionId)
|
||||
}
|
||||
}
|
||||
}, [protocol, resolvedConfig, tabId, updateTabStatus, t])
|
||||
}, [protocol, resolvedConfig, tabId, updateTabStatus, t, reconnectGeneration, scheduleDesktopReconnect])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) return
|
||||
@@ -334,13 +444,15 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
if (!pane || !canvas || fbSize.width <= 0 || fbSize.height <= 0) return
|
||||
|
||||
const updateFit = () => {
|
||||
const availW = Math.max(1, pane.clientWidth)
|
||||
const availH = Math.max(1, pane.clientHeight)
|
||||
const scale = Math.min(availW / fbSize.width, availH / fbSize.height)
|
||||
const displayW = Math.max(1, Math.floor(fbSize.width * scale))
|
||||
const displayH = Math.max(1, Math.floor(fbSize.height * scale))
|
||||
canvas.style.width = `${displayW}px`
|
||||
canvas.style.height = `${displayH}px`
|
||||
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 = () => {
|
||||
@@ -503,14 +615,28 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
}
|
||||
}
|
||||
|
||||
const pasteToRemote = async () => {
|
||||
const pasteToRemote = async (textFromEvent?: string) => {
|
||||
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 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) => {
|
||||
@@ -533,12 +659,12 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
const isPasteChord =
|
||||
pressed && (event.metaKey || event.ctrlKey) && event.code === 'KeyV' && !event.altKey && !event.shiftKey
|
||||
const isPasteChord = (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()
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -624,6 +750,16 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
)}
|
||||
|
||||
<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}
|
||||
@@ -707,14 +843,18 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
|
||||
<div
|
||||
ref={paneRef}
|
||||
className={`flex min-h-0 flex-1 items-center justify-center overflow-hidden bg-black ${
|
||||
fullscreenActive ? 'p-0' : 'p-2'
|
||||
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 shadow-lg shadow-black/40 outline-none"
|
||||
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)}
|
||||
@@ -722,6 +862,7 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
onMouseLeave={() => {
|
||||
buttonsRef.current = { left: false, middle: false, right: false }
|
||||
}}
|
||||
onPaste={onPasteClipboard}
|
||||
onKeyDown={(event) => onKey(event, true)}
|
||||
onKeyUp={(event) => onKey(event, false)}
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user