mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-12 21:52:13 +08:00
391 lines
11 KiB
TypeScript
391 lines
11 KiB
TypeScript
import { useCallback, useMemo, useSyncExternalStore } from 'react'
|
|
import { MihomoWebSocket } from 'tauri-plugin-mihomo-api'
|
|
|
|
const MAX_CLOSED_CONNS_NUM = 500
|
|
const CONNECTION_UPDATE_THROTTLE_MS = 500
|
|
const CONNECTION_RECONNECT_DELAY_MS = 1_000
|
|
|
|
type ConnectionMetadata = IConnectionsItem['metadata']
|
|
type ConnectionListener = () => void
|
|
|
|
const metadataValue = (value?: string) => value || ''
|
|
|
|
export const initConnData: ConnectionMonitorData = {
|
|
uploadTotal: 0,
|
|
downloadTotal: 0,
|
|
activeConnections: [],
|
|
closedConnections: [],
|
|
}
|
|
|
|
export interface ConnectionMonitorData {
|
|
uploadTotal: number
|
|
downloadTotal: number
|
|
activeConnections: IConnectionsItem[]
|
|
closedConnections: IConnectionsItem[]
|
|
}
|
|
|
|
export interface ConnectionSummaryData {
|
|
activeConnectionCount: number
|
|
}
|
|
|
|
export const initConnSummaryData: ConnectionSummaryData = {
|
|
activeConnectionCount: 0,
|
|
}
|
|
|
|
let connectionData: ConnectionMonitorData = initConnData
|
|
let connectionSummary: ConnectionSummaryData = initConnSummaryData
|
|
let connectionSocket: MihomoWebSocket | null = null
|
|
let connectionStarted = false
|
|
let connectionConnecting = false
|
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
let flushTimer: ReturnType<typeof setTimeout> | null = null
|
|
let pendingMessageData: string | null = null
|
|
let lastFlushAt = 0
|
|
|
|
const connectionListeners = new Set<ConnectionListener>()
|
|
const summaryListeners = new Set<ConnectionListener>()
|
|
|
|
const notifyConnectionListeners = () => {
|
|
connectionListeners.forEach((listener) => listener())
|
|
}
|
|
|
|
const notifySummaryListeners = () => {
|
|
summaryListeners.forEach((listener) => listener())
|
|
}
|
|
|
|
const sameMetadata = (left: ConnectionMetadata, right: ConnectionMetadata) =>
|
|
metadataValue(left.network) === metadataValue(right.network) &&
|
|
metadataValue(left.type) === metadataValue(right.type) &&
|
|
metadataValue(left.host) === metadataValue(right.host) &&
|
|
metadataValue(left.sourceIP) === metadataValue(right.sourceIP) &&
|
|
metadataValue(left.sourcePort) === metadataValue(right.sourcePort) &&
|
|
metadataValue(left.destinationPort) ===
|
|
metadataValue(right.destinationPort) &&
|
|
metadataValue(left.destinationIP) === metadataValue(right.destinationIP) &&
|
|
metadataValue(left.remoteDestination) ===
|
|
metadataValue(right.remoteDestination) &&
|
|
metadataValue(left.process) === metadataValue(right.process) &&
|
|
metadataValue(left.processPath) === metadataValue(right.processPath)
|
|
|
|
const normalizeMetadata = (
|
|
metadata: ConnectionMetadata,
|
|
previous?: ConnectionMetadata,
|
|
): ConnectionMetadata => {
|
|
if (previous && sameMetadata(previous, metadata)) return previous
|
|
|
|
return {
|
|
network: metadata.network || '',
|
|
type: metadata.type || '',
|
|
host: metadata.host || '',
|
|
sourceIP: metadata.sourceIP || '',
|
|
sourcePort: metadata.sourcePort || '',
|
|
destinationPort: metadata.destinationPort || '',
|
|
destinationIP: metadata.destinationIP || '',
|
|
remoteDestination: metadata.remoteDestination || '',
|
|
process: metadata.process || '',
|
|
processPath: metadata.processPath || '',
|
|
}
|
|
}
|
|
|
|
const sameChains = (left: string[], right: string[]) => {
|
|
if (left.length !== right.length) return false
|
|
for (let i = 0; i < left.length; i++) {
|
|
if (left[i] !== right[i]) return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
const normalizeChains = (chains: string[], previous?: string[]) => {
|
|
if (previous && sameChains(previous, chains)) return previous
|
|
return chains.slice()
|
|
}
|
|
|
|
const normalizeConnection = (
|
|
connection: IConnectionsItem,
|
|
previous?: IConnectionsItem,
|
|
): IConnectionsItem => {
|
|
const metadata = normalizeMetadata(connection.metadata, previous?.metadata)
|
|
const chains = normalizeChains(connection.chains || [], previous?.chains)
|
|
const upload = connection.upload ?? 0
|
|
const download = connection.download ?? 0
|
|
const curUpload = previous ? upload - previous.upload : 0
|
|
const curDownload = previous ? download - previous.download : 0
|
|
const rule = connection.rule || ''
|
|
const rulePayload = connection.rulePayload || ''
|
|
const start = connection.start || ''
|
|
|
|
if (
|
|
previous &&
|
|
previous.metadata === metadata &&
|
|
previous.chains === chains &&
|
|
previous.upload === upload &&
|
|
previous.download === download &&
|
|
previous.curUpload === curUpload &&
|
|
previous.curDownload === curDownload &&
|
|
previous.rule === rule &&
|
|
previous.rulePayload === rulePayload &&
|
|
previous.start === start
|
|
) {
|
|
return previous
|
|
}
|
|
|
|
return {
|
|
id: connection.id,
|
|
metadata,
|
|
upload,
|
|
download,
|
|
start,
|
|
chains,
|
|
rule,
|
|
rulePayload,
|
|
curUpload,
|
|
curDownload,
|
|
}
|
|
}
|
|
|
|
const mergeConnectionSnapshot = (
|
|
payload: IConnections,
|
|
previous: ConnectionMonitorData = initConnData,
|
|
): ConnectionMonitorData => {
|
|
const nextConnections = payload.connections ?? []
|
|
const previousActive = previous.activeConnections ?? []
|
|
const previousClosed = previous.closedConnections ?? []
|
|
const previousActiveById = new Map<string, IConnectionsItem>()
|
|
|
|
for (let i = 0; i < previousActive.length; i++) {
|
|
const previousConnection = previousActive[i]
|
|
previousActiveById.set(previousConnection.id, previousConnection)
|
|
}
|
|
|
|
const activeConnections: IConnectionsItem[] = []
|
|
for (let i = 0; i < nextConnections.length; i++) {
|
|
const connection = nextConnections[i]
|
|
const previousConnection = previousActiveById.get(connection.id)
|
|
if (previousConnection) previousActiveById.delete(connection.id)
|
|
activeConnections.push(normalizeConnection(connection, previousConnection))
|
|
}
|
|
|
|
if (previousActiveById.size === 0) {
|
|
return {
|
|
uploadTotal: payload.uploadTotal ?? 0,
|
|
downloadTotal: payload.downloadTotal ?? 0,
|
|
activeConnections,
|
|
closedConnections: previousClosed,
|
|
}
|
|
}
|
|
|
|
const removedConnectionCount = previousActiveById.size
|
|
const dropFromClosed = Math.max(
|
|
0,
|
|
previousClosed.length + removedConnectionCount - MAX_CLOSED_CONNS_NUM,
|
|
)
|
|
const closedConnections =
|
|
dropFromClosed >= previousClosed.length
|
|
? []
|
|
: previousClosed.slice(dropFromClosed)
|
|
|
|
const keepFromRemoved = MAX_CLOSED_CONNS_NUM - closedConnections.length
|
|
let skipRemoved = Math.max(0, removedConnectionCount - keepFromRemoved)
|
|
|
|
for (let i = 0; i < previousActive.length; i++) {
|
|
const connection = previousActive[i]
|
|
if (!previousActiveById.has(connection.id)) continue
|
|
if (skipRemoved > 0) {
|
|
skipRemoved -= 1
|
|
continue
|
|
}
|
|
closedConnections.push(connection)
|
|
}
|
|
|
|
return {
|
|
uploadTotal: payload.uploadTotal ?? 0,
|
|
downloadTotal: payload.downloadTotal ?? 0,
|
|
activeConnections,
|
|
closedConnections,
|
|
}
|
|
}
|
|
|
|
const mergeConnectionSummary = (
|
|
payload: IConnections,
|
|
): ConnectionSummaryData => ({
|
|
activeConnectionCount: payload.connections?.length ?? 0,
|
|
})
|
|
|
|
const flushPendingMessage = () => {
|
|
flushTimer = null
|
|
const messageData = pendingMessageData
|
|
pendingMessageData = null
|
|
if (!messageData) return
|
|
|
|
let payload: IConnections
|
|
try {
|
|
payload = JSON.parse(messageData) as IConnections
|
|
} catch (err) {
|
|
console.error('[Connections] Failed to parse websocket payload', err)
|
|
return
|
|
}
|
|
|
|
lastFlushAt = Date.now()
|
|
connectionSummary = mergeConnectionSummary(payload)
|
|
notifySummaryListeners()
|
|
|
|
if (connectionListeners.size === 0) return
|
|
|
|
connectionData = mergeConnectionSnapshot(payload, connectionData)
|
|
notifyConnectionListeners()
|
|
}
|
|
|
|
const enqueueConnectionMessage = (messageData: string) => {
|
|
pendingMessageData = messageData
|
|
if (flushTimer) return
|
|
|
|
const elapsed = Date.now() - lastFlushAt
|
|
if (elapsed >= CONNECTION_UPDATE_THROTTLE_MS) {
|
|
flushPendingMessage()
|
|
return
|
|
}
|
|
|
|
flushTimer = window.setTimeout(
|
|
flushPendingMessage,
|
|
CONNECTION_UPDATE_THROTTLE_MS - elapsed,
|
|
)
|
|
}
|
|
|
|
const clearReconnectTimer = () => {
|
|
if (!reconnectTimer) return
|
|
window.clearTimeout(reconnectTimer)
|
|
reconnectTimer = null
|
|
}
|
|
|
|
const closeConnectionSocket = async () => {
|
|
const socket = connectionSocket
|
|
connectionSocket = null
|
|
if (!socket) return
|
|
|
|
try {
|
|
await socket.close()
|
|
} catch (err) {
|
|
console.warn('Failed to close connection websocket', err)
|
|
}
|
|
}
|
|
|
|
const scheduleReconnect = () => {
|
|
if (reconnectTimer) return
|
|
reconnectTimer = window.setTimeout(() => {
|
|
reconnectTimer = null
|
|
void connectConnectionSocket()
|
|
}, CONNECTION_RECONNECT_DELAY_MS)
|
|
}
|
|
|
|
async function reconnectConnectionSocket() {
|
|
await closeConnectionSocket()
|
|
scheduleReconnect()
|
|
}
|
|
|
|
async function connectConnectionSocket() {
|
|
if (connectionSocket || connectionConnecting) return
|
|
|
|
clearReconnectTimer()
|
|
connectionConnecting = true
|
|
|
|
try {
|
|
const socket = await MihomoWebSocket.connect_connections()
|
|
connectionSocket = socket
|
|
socket.addListener((message) => {
|
|
if (message.type !== 'Text') return
|
|
if (message.data.startsWith('Websocket error')) {
|
|
void reconnectConnectionSocket()
|
|
return
|
|
}
|
|
|
|
enqueueConnectionMessage(message.data)
|
|
})
|
|
} catch {
|
|
scheduleReconnect()
|
|
} finally {
|
|
connectionConnecting = false
|
|
}
|
|
}
|
|
|
|
const startConnectionMonitor = () => {
|
|
if (connectionStarted) return
|
|
connectionStarted = true
|
|
void connectConnectionSocket()
|
|
}
|
|
|
|
const getConnectionSnapshot = () => connectionData
|
|
const getConnectionSummarySnapshot = () => connectionSummary
|
|
|
|
const subscribeConnectionData = (listener: ConnectionListener) => {
|
|
startConnectionMonitor()
|
|
connectionListeners.add(listener)
|
|
return () => {
|
|
connectionListeners.delete(listener)
|
|
}
|
|
}
|
|
|
|
const subscribeConnectionSummary = (listener: ConnectionListener) => {
|
|
startConnectionMonitor()
|
|
summaryListeners.add(listener)
|
|
return () => {
|
|
summaryListeners.delete(listener)
|
|
}
|
|
}
|
|
|
|
const refreshConnectionData = () => {
|
|
pendingMessageData = null
|
|
if (flushTimer) {
|
|
window.clearTimeout(flushTimer)
|
|
flushTimer = null
|
|
}
|
|
|
|
void reconnectConnectionSocket()
|
|
}
|
|
|
|
const clearClosedConnectionData = () => {
|
|
if (connectionData.closedConnections.length === 0) return
|
|
connectionData = {
|
|
...connectionData,
|
|
closedConnections: [],
|
|
}
|
|
notifyConnectionListeners()
|
|
}
|
|
|
|
export const useConnectionData = () => {
|
|
const data = useSyncExternalStore(
|
|
subscribeConnectionData,
|
|
getConnectionSnapshot,
|
|
getConnectionSnapshot,
|
|
)
|
|
const response = useMemo(() => ({ data }), [data])
|
|
const refreshGetClashConnection = useCallback(() => {
|
|
refreshConnectionData()
|
|
}, [])
|
|
const clearClosedConnections = useCallback(() => {
|
|
clearClosedConnectionData()
|
|
}, [])
|
|
|
|
return {
|
|
response,
|
|
refreshGetClashConnection,
|
|
clearClosedConnections,
|
|
}
|
|
}
|
|
|
|
export const useConnectionSummaryData = () => {
|
|
const data = useSyncExternalStore(
|
|
subscribeConnectionSummary,
|
|
getConnectionSummarySnapshot,
|
|
getConnectionSummarySnapshot,
|
|
)
|
|
const response = useMemo(() => ({ data }), [data])
|
|
const refreshGetClashConnectionSummary = useCallback(() => {
|
|
refreshConnectionData()
|
|
}, [])
|
|
|
|
return {
|
|
response,
|
|
refreshGetClashConnectionSummary,
|
|
}
|
|
}
|