Files
One-KVM/web/src/composables/useHidWebSocket.ts
mofeng-git ad401cdf1c refactor(web): 前端代码规范化重构
- 集中化 HID 类型定义到 types/hid.ts,消除重复代码
- 统一 WebSocket 连接管理,提取共享工具到 types/websocket.ts
- 拆分 ConsoleView.vue 关注点,创建 useVideoStream、useHidInput、useConsoleEvents composables
- 添加 useConfigPopover 抽象配置弹窗公共逻辑
- 优化视频容器布局,支持动态比例自适应
2026-01-02 21:24:47 +08:00

241 lines
6.6 KiB
TypeScript

// WebSocket HID channel for low-latency keyboard/mouse input (binary protocol)
// Uses the same binary format as WebRTC DataChannel for consistency
import { ref, onUnmounted } from 'vue'
import {
type HidKeyboardEvent,
type HidMouseEvent,
encodeKeyboardEvent,
encodeMouseEvent,
RESP_OK,
RESP_ERR_HID_UNAVAILABLE,
RESP_ERR_INVALID_MESSAGE,
} from '@/types/hid'
import { buildWsUrl, WS_RECONNECT_DELAY } from '@/types/websocket'
export type { HidKeyboardEvent, HidMouseEvent }
let wsInstance: WebSocket | null = null
const connected = ref(false)
const reconnectAttempts = ref(0)
const networkError = ref(false)
const networkErrorMessage = ref<string | null>(null)
let reconnectTimeout: number | null = null
const hidUnavailable = ref(false) // Track if HID is unavailable to prevent unnecessary reconnects
// Mouse throttle mechanism
let mouseThrottleMs = 10
let lastMouseSendTime = 0
let pendingMouseEvent: HidMouseEvent | null = null
let throttleTimer: number | null = null
// Connection promise to avoid race conditions
let connectionPromise: Promise<boolean> | null = null
let connectionResolved = false
function connect(): Promise<boolean> {
// If already connected, return immediately
if (wsInstance && wsInstance.readyState === WebSocket.OPEN && connectionResolved) {
return Promise.resolve(true)
}
// If connection is in progress, return the existing promise
if (connectionPromise && !connectionResolved) {
return connectionPromise
}
connectionResolved = false
connectionPromise = new Promise((resolve) => {
// Reset network error flag when attempting new connection
networkError.value = false
networkErrorMessage.value = null
hidUnavailable.value = false
const url = buildWsUrl('/api/ws/hid')
try {
wsInstance = new WebSocket(url)
wsInstance.binaryType = 'arraybuffer'
wsInstance.onopen = () => {
connected.value = true
networkError.value = false
reconnectAttempts.value = 0
}
wsInstance.onmessage = (e) => {
// Handle binary response
if (e.data instanceof ArrayBuffer) {
const view = new DataView(e.data)
if (view.byteLength >= 1) {
const code = view.getUint8(0)
if (code === RESP_OK) {
hidUnavailable.value = false
networkError.value = false
connectionResolved = true
resolve(true)
} else if (code === RESP_ERR_HID_UNAVAILABLE) {
// HID is not available, mark it and don't trigger reconnection
hidUnavailable.value = true
networkError.value = false
connectionResolved = true
resolve(true)
} else if (code === RESP_ERR_INVALID_MESSAGE) {
console.warn('[HID] Server rejected message as invalid')
}
}
}
}
wsInstance.onclose = () => {
connected.value = false
connectionResolved = false
connectionPromise = null
// Don't auto-reconnect if HID is unavailable
if (hidUnavailable.value) {
resolve(false)
return
}
// Auto-reconnect with infinite retry for network errors
networkError.value = true
networkErrorMessage.value = 'HID WebSocket disconnected'
reconnectAttempts.value++
reconnectTimeout = window.setTimeout(() => connect(), WS_RECONNECT_DELAY)
}
wsInstance.onerror = () => {
networkError.value = true
networkErrorMessage.value = 'Network connection failed'
connectionResolved = false
connectionPromise = null
resolve(false)
}
} catch (err) {
console.error('[HID] Failed to create connection:', err)
connectionResolved = false
connectionPromise = null
resolve(false)
}
})
return connectionPromise
}
function disconnect() {
if (reconnectTimeout !== null) {
clearTimeout(reconnectTimeout)
reconnectTimeout = null
}
if (wsInstance) {
// Close the websocket
wsInstance.close()
wsInstance = null
connected.value = false
networkError.value = false
}
// Reset connection state
connectionPromise = null
connectionResolved = false
}
function sendKeyboard(event: HidKeyboardEvent): Promise<void> {
return new Promise((resolve, reject) => {
if (!wsInstance || wsInstance.readyState !== WebSocket.OPEN) {
reject(new Error('WebSocket not connected'))
return
}
try {
wsInstance.send(encodeKeyboardEvent(event))
resolve()
} catch (err) {
reject(err)
}
})
}
// Set mouse throttle interval (0-1000ms, 0 = no throttle)
export function setMouseThrottle(ms: number) {
mouseThrottleMs = Math.max(0, Math.min(1000, ms))
}
// Internal function to actually send mouse event
function _sendMouseInternal(event: HidMouseEvent): Promise<void> {
return new Promise((resolve, reject) => {
if (!wsInstance || wsInstance.readyState !== WebSocket.OPEN) {
reject(new Error('WebSocket not connected'))
return
}
try {
wsInstance.send(encodeMouseEvent(event))
resolve()
} catch (err) {
reject(err)
}
})
}
// Throttled mouse event sender
function sendMouse(event: HidMouseEvent): Promise<void> {
return new Promise((resolve, reject) => {
const now = Date.now()
const elapsed = now - lastMouseSendTime
if (elapsed >= mouseThrottleMs) {
// Send immediately if enough time has passed
lastMouseSendTime = now
_sendMouseInternal(event).then(resolve).catch(reject)
} else {
// Queue the event and send after throttle period
pendingMouseEvent = event
// Clear existing timer
if (throttleTimer !== null) {
clearTimeout(throttleTimer)
}
// Schedule send after remaining throttle time
throttleTimer = window.setTimeout(() => {
if (pendingMouseEvent) {
lastMouseSendTime = Date.now()
_sendMouseInternal(pendingMouseEvent)
.then(resolve)
.catch(reject)
pendingMouseEvent = null
}
}, mouseThrottleMs - elapsed)
}
})
}
export function useHidWebSocket() {
onUnmounted(() => {
// Don't disconnect on component unmount - WebSocket is shared
// Only disconnect when explicitly called or page unloads
})
return {
connected,
reconnectAttempts,
networkError,
networkErrorMessage,
hidUnavailable,
connect,
disconnect,
sendKeyboard,
sendMouse,
}
}
// Global lifecycle - disconnect when page unloads
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', () => {
disconnect()
})
}