chore: 保存工作副本——客户端授权新契约(双端点/验签/令牌/周检)与桌面会话可靠性改进

This commit is contained in:
Logan.Li committed 2026-09-14 22:10:22 +08:00
1 parent f71eef0eba
commit ae6093c9b3
163 files changed
+4911 -2494

No files matched your search

+169 -28
View File
@@ -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)}
/>