fix: hidRPC handshake packet should be only sent once (#969)

This commit is contained in:
Aveline 2025-11-21 13:31:13 +01:00 committed by GitHub
parent 316c2e6d37
commit c472752c56
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 151 additions and 63 deletions

13
ui/package-lock.json generated
View File

@ -37,6 +37,7 @@
"react-xtermjs": "^1.0.10",
"recharts": "^3.3.0",
"tailwind-merge": "^3.3.1",
"tslog": "^4.10.2",
"usehooks-ts": "^3.1.1",
"validator": "^13.15.20",
"zustand": "^4.5.2"
@ -7330,6 +7331,18 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tslog": {
"version": "4.10.2",
"resolved": "https://registry.npmjs.org/tslog/-/tslog-4.10.2.tgz",
"integrity": "sha512-XuELoRpMR+sq8fuWwX7P0bcj+PRNiicOKDEb3fGNURhxWVyykCi9BNq7c4uVz7h7P0sj8qgBsr5SWS6yBClq3g==",
"license": "MIT",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/fullstack-build/tslog?sponsor=1"
}
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View File

@ -56,6 +56,7 @@
"react-xtermjs": "^1.0.10",
"recharts": "^3.3.0",
"tailwind-merge": "^3.3.1",
"tslog": "^4.10.2",
"usehooks-ts": "^3.1.1",
"validator": "^13.15.20",
"zustand": "^4.5.2"

View File

@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo } from "react";
import { Logger } from "tslog";
import { useRTCStore } from "@hooks/stores";
@ -25,6 +26,128 @@ interface sendMessageParams {
requireOrdered?: boolean;
}
const HANDSHAKE_TIMEOUT = 30 * 1000; // 30 seconds
const HANDSHAKE_MAX_ATTEMPTS = 10;
const logger = new Logger({ name: "hidrpc" });
export function doRpcHidHandshake(rpcHidChannel: RTCDataChannel, setRpcHidProtocolVersion: (version: number | null) => void) {
let attempts = 0;
let lastConnectedTime: Date | undefined;
let lastSendTime: Date | undefined;
let handshakeCompleted = false;
let handshakeInterval: ReturnType<typeof setInterval> | null = null;
const shouldGiveUp = () => {
if (attempts > HANDSHAKE_MAX_ATTEMPTS) {
logger.error(`Failed to send handshake message after ${HANDSHAKE_MAX_ATTEMPTS} attempts`);
return true;
}
const timeSinceConnected = lastConnectedTime ? Date.now() - lastConnectedTime.getTime() : 0;
if (timeSinceConnected > HANDSHAKE_TIMEOUT) {
logger.error(`Handshake timed out after ${timeSinceConnected}ms`);
return true;
}
return false;
}
const sendHandshake = (initial: boolean) => {
if (handshakeCompleted) return;
attempts++;
lastSendTime = new Date();
if (!initial && shouldGiveUp()) {
if (handshakeInterval) {
clearInterval(handshakeInterval);
handshakeInterval = null;
}
return;
}
let data: Uint8Array | undefined;
try {
const message = new HandshakeMessage(HID_RPC_VERSION);
data = message.marshal();
} catch (e) {
logger.error("Failed to marshal message", e);
return;
}
if (!data) return;
rpcHidChannel.send(data as unknown as ArrayBuffer);
if (initial) {
handshakeInterval = setInterval(() => {
sendHandshake(false);
}, 1000);
}
};
const onMessage = (ev: MessageEvent) => {
const message = unmarshalHidRpcMessage(new Uint8Array(ev.data));
if (!message || !(message instanceof HandshakeMessage)) return;
if (!message.version) {
logger.error("Received handshake message without version", message);
return;
}
if (message.version > HID_RPC_VERSION) {
// we assume that the UI is always using the latest version of the HID RPC protocol
// so we can't support this
// TODO: use capabilities to determine rather than version number
logger.error("Server is using a newer version than the client", message);
return;
}
setRpcHidProtocolVersion(message.version);
const timeUsed = lastSendTime ? Date.now() - lastSendTime.getTime() : 0;
logger.info(`Handshake completed in ${timeUsed}ms after ${attempts} attempts (Version: ${message.version} / ${HID_RPC_VERSION})`);
// clean up
rpcHidChannel.removeEventListener("message", onMessage);
resetHandshake({ completed: true });
};
const resetHandshake = ({ lastConnectedTime: newLastConnectedTime, completed }: { lastConnectedTime?: Date | undefined, completed?: boolean }) => {
if (newLastConnectedTime) lastConnectedTime = newLastConnectedTime;
lastSendTime = undefined;
attempts = 0;
if (completed !== undefined) handshakeCompleted = completed;
if (handshakeInterval) {
clearInterval(handshakeInterval);
handshakeInterval = null;
}
};
const onConnected = () => {
resetHandshake({ lastConnectedTime: new Date() });
logger.info("Channel connected");
sendHandshake(true);
rpcHidChannel.addEventListener("message", onMessage);
};
const onClose = () => {
resetHandshake({ lastConnectedTime: undefined, completed: false });
logger.info("Channel closed");
setRpcHidProtocolVersion(null);
rpcHidChannel.removeEventListener("message", onMessage);
};
rpcHidChannel.addEventListener("open", onConnected);
rpcHidChannel.addEventListener("close", onClose);
// handle case where channel is already open when the hook is mounted
if (rpcHidChannel.readyState === "open") {
onConnected();
}
}
export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) {
const {
rpcHidChannel,
@ -78,7 +201,7 @@ export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) {
try {
data = message.marshal();
} catch (e) {
console.error("Failed to marshal HID RPC message", e);
logger.error("Failed to marshal message", e);
}
if (!data) return;
@ -151,99 +274,46 @@ export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) {
sendMessage(KEEPALIVE_MESSAGE);
}, [sendMessage]);
const sendHandshake = useCallback(() => {
if (hidRpcDisabled) return;
if (rpcHidProtocolVersion) return;
if (!rpcHidChannel) return;
sendMessage(new HandshakeMessage(HID_RPC_VERSION), { ignoreHandshakeState: true });
}, [rpcHidChannel, rpcHidProtocolVersion, sendMessage, hidRpcDisabled]);
const handleHandshake = useCallback(
(message: HandshakeMessage) => {
if (hidRpcDisabled) return;
if (!message.version) {
console.error("Received handshake message without version", message);
return;
}
if (message.version > HID_RPC_VERSION) {
// we assume that the UI is always using the latest version of the HID RPC protocol
// so we can't support this
// TODO: use capabilities to determine rather than version number
console.error("Server is using a newer HID RPC version than the client", message);
return;
}
setRpcHidProtocolVersion(message.version);
},
[setRpcHidProtocolVersion, hidRpcDisabled],
);
useEffect(() => {
if (!rpcHidChannel) return;
if (hidRpcDisabled) return;
// send handshake message
sendHandshake();
const messageHandler = (e: MessageEvent) => {
if (typeof e.data === "string") {
console.warn("Received string data in HID RPC message handler", e.data);
logger.warn("Received string data in message handler", e.data);
return;
}
const message = unmarshalHidRpcMessage(new Uint8Array(e.data));
if (!message) {
console.warn("Received invalid HID RPC message", e.data);
logger.warn("Received invalid message", e.data);
return;
}
console.debug("Received HID RPC message", message);
switch (message.constructor) {
case HandshakeMessage:
handleHandshake(message as HandshakeMessage);
break;
default:
// not all events are handled here, the rest are handled by the onHidRpcMessage callback
break;
}
if (message instanceof HandshakeMessage) return; // handshake message is handled by the doRpcHidHandshake function
// to remove it from the production build, we need to use the /* @__PURE__ */ comment here
// setting `esbuild.pure` doesn't work
/* @__PURE__ */ logger.debug("Received message", message);
onHidRpcMessage?.(message);
};
const openHandler = () => {
console.info("HID RPC channel opened");
sendHandshake();
};
const closeHandler = () => {
console.info("HID RPC channel closed");
setRpcHidProtocolVersion(null);
};
const errorHandler = (e: Event) => {
console.error(`Error on rpcHidChannel '${rpcHidChannel.label}': ${e}`)
logger.error(`Error on channel '${rpcHidChannel.label}'`, e);
};
rpcHidChannel.addEventListener("message", messageHandler);
rpcHidChannel.addEventListener("close", closeHandler);
rpcHidChannel.addEventListener("error", errorHandler);
rpcHidChannel.addEventListener("open", openHandler);
return () => {
rpcHidChannel.removeEventListener("message", messageHandler);
rpcHidChannel.removeEventListener("close", closeHandler);
rpcHidChannel.removeEventListener("error", errorHandler);
rpcHidChannel.removeEventListener("open", openHandler);
};
}, [
rpcHidChannel,
onHidRpcMessage,
setRpcHidProtocolVersion,
sendHandshake,
handleHandshake,
hidRpcDisabled,
]);

View File

@ -53,6 +53,7 @@ import {
} from "@components/VideoOverlay";
import { FeatureFlagProvider } from "@providers/FeatureFlagProvider";
import { m } from "@localizations/messages.js";
import { doRpcHidHandshake } from "@hooks/useHidRpc";
export type AuthMode = "password" | "noPassword" | null;
@ -127,6 +128,7 @@ export default function KvmIdRoute() {
setRpcHidChannel,
setRpcHidUnreliableNonOrderedChannel,
setRpcHidUnreliableChannel,
setRpcHidProtocolVersion,
} = useRTCStore();
const location = useLocation();
@ -498,6 +500,7 @@ export default function KvmIdRoute() {
rpcHidChannel.onopen = () => {
setRpcHidChannel(rpcHidChannel);
};
doRpcHidHandshake(rpcHidChannel, setRpcHidProtocolVersion);
const rpcHidUnreliableChannel = pc.createDataChannel("hidrpc-unreliable-ordered", {
ordered: true,
@ -534,6 +537,7 @@ export default function KvmIdRoute() {
setRpcHidChannel,
setRpcHidUnreliableNonOrderedChannel,
setRpcHidUnreliableChannel,
setRpcHidProtocolVersion,
setTransceiver,
]);

View File

@ -39,7 +39,7 @@ export default defineConfig(({ mode, command }) => {
return {
plugins,
esbuild: {
pure: ["console.debug"],
pure: command === "build" ? ["console.debug"]: [],
},
assetsInclude: ["**/*.woff2"],
build: {