Compare commits

..

2 Commits

Author SHA1 Message Date
Marc Brooks 5e6da5b782
Merge 94f6bbd27a into 608f69db13 2025-08-15 21:23:20 +00:00
Marc Brooks 94f6bbd27a
Add ability to track modifier state on the device
Remove LED sync source and add keypress reporting
We return the modifiers as the valid bitmask so that the VirtualKeyboard can represent the correct keys as down. This is important when we have strokes like Left-Control + Right-Control + Keypad-1 (used in switching KVMs and such)
Fix handling of meta keys in client
Ran go modernize
Morphs Interface{} to any
Ranges over SplitSeq and FieldSeq for iterating splits
Used min for end calculation remote_mount.Read
Used range 16 in wol.createMagicPacket
DID NOT apply the Omitempty cleanup.
Use the KeysDownState for the infobar
Strong typed in the typescript realm.
Enable still working with devices that haven't been upgraded
Return the KeysDownState from keyboardReport
Clear out the hidErrorRollOver once sent to reset the keyboard to nothing down.
Handles the returned KeysDownState from keyboardReport
Now passes all logic through handleKeyPress.
If we get a state back from a keyboardReport, use it and also enable keypressReport because we now know it's an upgraded device.
Add documentation on the legacy support.
Cleanup react state management to enable upgrading Zustand
2025-08-15 16:21:43 -05:00
5 changed files with 96 additions and 87 deletions

26
ui/package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "kvm-ui",
"version": "2025.08.15.001",
"version": "2025.08.15.2119",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "kvm-ui",
"version": "2025.08.15.001",
"version": "2025.08.15.2119",
"dependencies": {
"@headlessui/react": "^2.2.7",
"@headlessui/tailwindcss": "^0.2.2",
@ -28,10 +28,10 @@
"react": "^19.1.1",
"react-animate-height": "^3.2.3",
"react-dom": "^19.1.1",
"react-hot-toast": "^2.5.2",
"react-hot-toast": "^2.6.0",
"react-icons": "^5.5.0",
"react-router-dom": "^6.22.3",
"react-simple-keyboard": "^3.8.109",
"react-simple-keyboard": "^3.8.111",
"react-use-websocket": "^4.13.0",
"react-xtermjs": "^1.0.10",
"recharts": "^2.15.3",
@ -3156,9 +3156,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.201",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.201.tgz",
"integrity": "sha512-ZG65vsrLClodGqywuigc+7m0gr4ISoTQttfVh7nfpLv0M7SIwF4WbFNEOywcqTiujs12AUeeXbFyQieDICAIxg==",
"version": "1.5.202",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.202.tgz",
"integrity": "sha512-NxbYjRmiHcHXV1Ws3fWUW+SLb62isauajk45LUJ/HgIOkUA7jLZu/X2Iif+X9FBNK8QkF9Zb4Q2mcwXCcY30mg==",
"dev": true,
"license": "ISC"
},
@ -5784,9 +5784,9 @@
}
},
"node_modules/react-hot-toast": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.5.2.tgz",
"integrity": "sha512-Tun3BbCxzmXXM7C+NI4qiv6lT0uwGh4oAfeJyNOjYUejTsm35mK9iCaYLGv8cBz9L5YxZLx/2ii7zsIwPtPUdw==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz",
"integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==",
"license": "MIT",
"dependencies": {
"csstype": "^3.1.3",
@ -5848,9 +5848,9 @@
}
},
"node_modules/react-simple-keyboard": {
"version": "3.8.109",
"resolved": "https://registry.npmjs.org/react-simple-keyboard/-/react-simple-keyboard-3.8.109.tgz",
"integrity": "sha512-FLlivKL4tb5G2cWOo2slOrMEkzzFX0Yg8P7k5qzisN8+TnqUPq+8G7N8D2+0oVkSmfeqZn6PyLCurGSitK4QIQ==",
"version": "3.8.111",
"resolved": "https://registry.npmjs.org/react-simple-keyboard/-/react-simple-keyboard-3.8.111.tgz",
"integrity": "sha512-zR6qeGeH1bhaP8GDMLwRBqpyU98jGUOmuNKZT6Z0056kjR4EVRo99Z/eVCafN0ySKpweQ6x0gVAjxkegy6EDFg==",
"license": "MIT",
"peerDependencies": {
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",

View File

@ -1,7 +1,7 @@
{
"name": "kvm-ui",
"private": true,
"version": "2025.08.15.001",
"version": "2025.08.15.2119",
"type": "module",
"engines": {
"node": "22.15.0"
@ -39,10 +39,10 @@
"react": "^19.1.1",
"react-animate-height": "^3.2.3",
"react-dom": "^19.1.1",
"react-hot-toast": "^2.5.2",
"react-hot-toast": "^2.6.0",
"react-icons": "^5.5.0",
"react-router-dom": "^6.22.3",
"react-simple-keyboard": "^3.8.109",
"react-simple-keyboard": "^3.8.111",
"react-use-websocket": "^4.13.0",
"react-xtermjs": "^1.0.10",
"recharts": "^2.15.3",

View File

@ -36,7 +36,7 @@ export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
const { rpcDataChannel } = useRTCStore();
const send = useCallback(
(method: string, params: unknown, callback?: (resp: JsonRpcResponse) => void) => {
async (method: string, params: unknown, callback?: (resp: JsonRpcResponse) => void) => {
if (rpcDataChannel?.readyState !== "open") return;
requestCounter++;
const payload = { jsonrpc: "2.0", method, params, id: requestCounter };
@ -61,7 +61,7 @@ export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
return;
}
if ("error" in payload) console.error(payload.error);
if ("error" in payload) console.error("RPC error", payload);
if (!payload.id) return;
const callback = callbackStore.get(payload.id);

View File

@ -28,7 +28,7 @@ export default function useKeyboard() {
// The device will respond with the keysDownState if it supports the keyPressReport API
// or just accept the state if it does not support (returning no result)
const sendKeyboardEvent = useCallback(
(state: KeysDownState) => {
async (state: KeysDownState) => {
if (rpcDataChannel?.readyState !== "open") return;
console.debug(`Send keyboardReport keys: ${state.keys}, modifier: ${state.modifier}`);
@ -45,13 +45,14 @@ export default function useKeyboard() {
setkeyPressReportApiAvailable(true); // if they returned a keysDownState, we ALSO know they also support keyPressReport
} else {
// older devices versions do not return the keyDownState
setKeysDownState(state); // we just pretend they accepted what we sent
// so we just pretend they accepted what we sent
setKeysDownState(state);
setkeyPressReportApiAvailable(false); // we ALSO know they do not support keyPressReport
}
}
});
},
[rpcDataChannel?.readyState, send, setkeyPressReportApiAvailable, setKeysDownState],
[rpcDataChannel?.readyState, send, setKeysDownState, setkeyPressReportApiAvailable],
);
// sendKeypressEvent is used to send a single key press/release event to the device.
@ -61,7 +62,7 @@ export default function useKeyboard() {
// In that case we will switch to local key handling and update the keysDownState
// in client/browser-side code using simulateDeviceSideKeyHandlingForLegacyDevices.
const sendKeypressEvent = useCallback(
(key: number, press: boolean) => {
async (key: number, press: boolean) => {
if (rpcDataChannel?.readyState !== "open") return;
console.debug(`Send keypressEvent key: ${key}, press: ${press}`);
@ -90,12 +91,15 @@ export default function useKeyboard() {
// resetKeyboardState is used to reset the keyboard state to no keys pressed and no modifiers.
// This is useful for macros and when the browser loses focus to ensure that the keyboard state
// is clean.
const resetKeyboardState = useCallback(() => {
console.debug("Resetting keyboard state");
keysDownState.keys.fill(0); // Reset the keys buffer to zeros
keysDownState.modifier = 0; // Reset the modifier state to zero
sendKeyboardEvent(keysDownState);
}, [keysDownState, sendKeyboardEvent]);
const resetKeyboardState = useCallback(
async () => {
console.debug("Resetting keyboard state");
// Reset the keys buffer to zeros and the modifier state to zero
keysDownState.keys.length = hidKeyBufferSize;
keysDownState.keys.fill(0);
keysDownState.modifier = 0;
sendKeyboardEvent(keysDownState);
}, [keysDownState, sendKeyboardEvent]);
// executeMacro is used to execute a macro consisting of multiple steps.
// Each step can have multiple keys, multiple modifiers and a delay.
@ -133,7 +137,7 @@ export default function useKeyboard() {
// handling for legacy devices and updates the keysDownState accordingly.
// It then sends the full keyboard state to the device.
const handleKeyPress = useCallback(
(key: number, press: boolean) => {
async (key: number, press: boolean) => {
if (rpcDataChannel?.readyState !== "open") return;
if (keyPressReportApiAvailable) {
@ -143,9 +147,14 @@ export default function useKeyboard() {
// if the keyPress api is not available, we need to handle the key locally
const downState = simulateDeviceSideKeyHandlingForLegacyDevices(keysDownState, key, press);
sendKeyboardEvent(downState); // then we send the full state
// if we just sent ErrorRollOver, reset to empty state
if (downState.keys[0] === hidErrorRollOver) {
resetKeyboardState();
}
}
},
[keyPressReportApiAvailable, keysDownState, rpcDataChannel?.readyState, sendKeyboardEvent, sendKeypressEvent],
[keyPressReportApiAvailable, keysDownState, resetKeyboardState, rpcDataChannel?.readyState, sendKeyboardEvent, sendKeypressEvent],
);
// IMPORTANT: See the keyPressReportApiAvailable comment above for the reason this exists
@ -154,8 +163,8 @@ export default function useKeyboard() {
// for handling key presses and releases. It ensures that the USB gadget
// behaves similarly to a real USB HID keyboard. This logic is paralleled
// in the device-side code in hid_keyboard.go so make sure to keep them in sync.
const keys = state.keys;
let modifiers = state.modifier;
const keys = state.keys;
const modifierMask = hidKeyToModifierMask[key] || 0;
if (modifierMask !== 0) {
@ -163,7 +172,6 @@ export default function useKeyboard() {
// by setting or clearing the corresponding bit in the modifier byte.
// This allows us to track the state of dynamic modifier keys like
// Shift, Control, Alt, and Super.
console.debug(`Handling modifier key: ${key}, press: ${press}, current modifiers: ${modifiers}, modifier mask: ${modifierMask}`);
if (press) {
modifiers |= modifierMask;
} else {
@ -173,34 +181,35 @@ export default function useKeyboard() {
// handle other keys that are not modifier keys by placing or removing them
// from the key buffer since the buffer tracks currently pressed keys
let overrun = true;
for (let i = 0; i < hidKeyBufferSize && overrun; i++) {
for (let i = 0; i < hidKeyBufferSize; i++) {
// If we find the key in the buffer the buffer, we either remove it (if press is false)
// or do nothing (if down is true) because the buffer tracks currently pressed keys
// and if we find a zero byte, we can place the key there (if press is true)
if (keys[i] == key || keys[i] == 0) {
if (keys[i] === key || keys[i] === 0) {
if (press) {
keys[i] = key // overwrites the zero byte or the same key if already pressed
} else {
// we are releasing the key, remove it from the buffer
if (keys[i] != 0) {
if (keys[i] !== 0) {
keys.splice(i, 1);
keys.push(0); // add a zero at the end
}
}
overrun = false // We found a slot for the key
overrun = false; // We found a slot for the key
break;
}
}
// If we reach here it means we didn't find an empty slot or the key in the buffer
if (overrun) {
if (press) {
console.warn(`keyboard buffer overflow, key: ${key} not added`);
// Fill all key slots with ErrorRollOver (0x01) to indicate overflow
keys.length = 6;
keys.fill(hidErrorRollOver);
} else {
// If we are releasing a key, and we didn't find it in a slot, who cares?
console.debug(`key ${key} not found in buffer, nothing to release`)
}
// If we reach here it means we didn't find an empty slot or the key in the buffer
if (overrun) {
if (press) {
console.warn(`keyboard buffer overflow current keys ${keys}, key: ${key} not added`);
// Fill all key slots with ErrorRollOver (0x01) to indicate overflow
keys.length = hidKeyBufferSize;
keys.fill(hidErrorRollOver);
} else {
// If we are releasing a key, and we didn't find it in a slot, who cares?
console.debug(`key ${key} not found in buffer, nothing to release`)
}
}
}

View File

@ -248,27 +248,27 @@ export default function KvmIdRoute() {
reconnectAttempts: 15,
reconnectInterval: 1000,
onReconnectStop: () => {
console.log("Reconnect stopped");
console.debug("Reconnect stopped");
cleanupAndStopReconnecting();
},
shouldReconnect(event) {
console.log("[Websocket] shouldReconnect", event);
console.debug("[Websocket] shouldReconnect", event);
// TODO: Why true?
return true;
},
onClose(event) {
console.log("[Websocket] onClose", event);
console.debug("[Websocket] onClose", event);
// We don't want to close everything down, we wait for the reconnect to stop instead
},
onError(event) {
console.log("[Websocket] onError", event);
console.error("[Websocket] onError", event);
// We don't want to close everything down, we wait for the reconnect to stop instead
},
onOpen() {
console.log("[Websocket] onOpen");
console.debug("[Websocket] onOpen");
},
onMessage: message => {
@ -290,8 +290,8 @@ export default function KvmIdRoute() {
const parsedMessage = JSON.parse(message.data);
if (parsedMessage.type === "device-metadata") {
const { deviceVersion } = parsedMessage.data;
console.log("[Websocket] Received device-metadata message");
console.log("[Websocket] Device version", deviceVersion);
console.debug("[Websocket] Received device-metadata message");
console.debug("[Websocket] Device version", deviceVersion);
// If the device version is not set, we can assume the device is using the legacy signaling
if (!deviceVersion) {
console.log("[Websocket] Device is using legacy signaling");
@ -309,7 +309,7 @@ export default function KvmIdRoute() {
if (!peerConnection) return;
if (parsedMessage.type === "answer") {
console.log("[Websocket] Received answer");
console.debug("[Websocket] Received answer");
const readyForOffer =
// If we're making an offer, we don't want to accept an answer
!makingOffer &&
@ -323,7 +323,7 @@ export default function KvmIdRoute() {
// Set so we don't accept an answer while we're setting the remote description
isSettingRemoteAnswerPending.current = parsedMessage.type === "answer";
console.log(
console.debug(
"[Websocket] Setting remote answer pending",
isSettingRemoteAnswerPending.current,
);
@ -339,7 +339,7 @@ export default function KvmIdRoute() {
// Reset the remote answer pending flag
isSettingRemoteAnswerPending.current = false;
} else if (parsedMessage.type === "new-ice-candidate") {
console.log("[Websocket] Received new-ice-candidate");
console.debug("[Websocket] Received new-ice-candidate");
const candidate = parsedMessage.data;
peerConnection.addIceCandidate(candidate);
}
@ -385,7 +385,7 @@ export default function KvmIdRoute() {
return;
}
console.log("Successfully got Remote Session Description. Setting.");
console.debug("Successfully got Remote Session Description. Setting.");
setLoadingMessage("Setting remote session description...");
const decodedSd = atob(json.sd);
@ -396,13 +396,13 @@ export default function KvmIdRoute() {
);
const setupPeerConnection = useCallback(async () => {
console.log("[setupPeerConnection] Setting up peer connection");
console.debug("[setupPeerConnection] Setting up peer connection");
setConnectionFailed(false);
setLoadingMessage("Connecting to device...");
let pc: RTCPeerConnection;
try {
console.log("[setupPeerConnection] Creating peer connection");
console.debug("[setupPeerConnection] Creating peer connection");
setLoadingMessage("Creating peer connection...");
pc = new RTCPeerConnection({
// We only use STUN or TURN servers if we're in the cloud
@ -412,7 +412,7 @@ export default function KvmIdRoute() {
});
setPeerConnectionState(pc.connectionState);
console.log("[setupPeerConnection] Peer connection created", pc);
console.debug("[setupPeerConnection] Peer connection created", pc);
setLoadingMessage("Setting up connection to device...");
} catch (e) {
console.error(`[setupPeerConnection] Error creating peer connection: ${e}`);
@ -424,13 +424,13 @@ export default function KvmIdRoute() {
// Set up event listeners and data channels
pc.onconnectionstatechange = () => {
console.log("[setupPeerConnection] Connection state changed", pc.connectionState);
console.debug("[setupPeerConnection] Connection state changed", pc.connectionState);
setPeerConnectionState(pc.connectionState);
};
pc.onnegotiationneeded = async () => {
try {
console.log("[setupPeerConnection] Creating offer");
console.debug("[setupPeerConnection] Creating offer");
makingOffer.current = true;
const offer = await pc.createOffer();
@ -462,7 +462,7 @@ export default function KvmIdRoute() {
pc.onicegatheringstatechange = event => {
const pc = event.currentTarget as RTCPeerConnection;
if (pc.iceGatheringState === "complete") {
console.log("ICE Gathering completed");
console.debug("ICE Gathering completed");
setLoadingMessage("ICE Gathering completed");
if (isLegacySignalingEnabled.current) {
@ -470,7 +470,7 @@ export default function KvmIdRoute() {
legacyHTTPSignaling(pc);
}
} else if (pc.iceGatheringState === "gathering") {
console.log("ICE Gathering Started");
console.debug("ICE Gathering Started");
setLoadingMessage("Gathering ICE candidates...");
}
};
@ -597,11 +597,15 @@ export default function KvmIdRoute() {
}
if (resp.method === "usbState") {
setUsbState(resp.params as unknown as USBStates);
const usbState = resp.params as unknown as USBStates;
console.debug("Setting USB state", usbState);
setUsbState(usbState);
}
if (resp.method === "videoInputState") {
setHdmiState(resp.params as Parameters<VideoState["setHdmiState"]>[0]);
const hdmiState = resp.params as Parameters<VideoState["setHdmiState"]>[0];
console.debug("Setting HDMI state", hdmiState);
setHdmiState(hdmiState);
}
if (resp.method === "networkState") {
@ -617,16 +621,14 @@ export default function KvmIdRoute() {
if (resp.method === "keysDownState") {
const downState = resp.params as KeysDownState;
if (downState) {
console.debug("Setting key down state:", downState);
setKeysDownState(downState);
setkeyPressReportApiAvailable(true); // if they returned a keyDownState, we know they also support keyPressReport
}
console.debug("Setting key down state:", downState);
setKeysDownState(downState);
setkeyPressReportApiAvailable(true); // if they returned a keyDownState, we know they also support keyPressReport
}
if (resp.method === "otaState") {
const otaState = resp.params as OtaState;
console.debug("Setting OTA state", otaState);
setOtaState(otaState);
if (otaState.updating === true) {
@ -654,9 +656,12 @@ export default function KvmIdRoute() {
useEffect(() => {
if (rpcDataChannel?.readyState !== "open") return;
console.log("Requesting video state");
send("getVideoState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return;
setHdmiState(resp.result as Parameters<VideoState["setHdmiState"]>[0]);
const hdmiState = resp.result as Parameters<VideoState["setHdmiState"]>[0];
console.debug("Setting HDMI state", hdmiState);
setHdmiState(hdmiState);
});
}, [rpcDataChannel?.readyState, send, setHdmiState]);
@ -672,12 +677,10 @@ export default function KvmIdRoute() {
if ("error" in resp) {
console.error("Failed to get keyboard led state", resp.error);
return;
}
const ledState = resp.result as KeyboardLedState;
if (ledState) {
console.debug("Keyboard led state: ", resp.result);
setKeyboardLedState(resp.result as KeyboardLedState);
} else {
const ledState = resp.result as KeyboardLedState;
console.debug("Keyboard led state: ", ledState);
setKeyboardLedState(ledState);
}
setNeedLedState(false);
});
@ -696,19 +699,16 @@ export default function KvmIdRoute() {
// -32601 means the method is not supported
if (resp.error.code === -32601) {
// if we don't support key down state, we know key press is also not available
console.error("Failed to get key down state, switching to old-school", resp.error);
console.warn("Failed to get key down state, switching to old-school", resp.error);
setkeyPressReportApiAvailable(false);
} else {
console.error("Failed to get key down state", resp.error);
}
} else {
const downState = resp.result as KeysDownState;
if (downState) {
console.debug("Keyboard key down state", downState);
setKeysDownState(downState);
setkeyPressReportApiAvailable(true); // if they returned a keyDownState, we know they also support keyPressReport
}
console.debug("Keyboard key down state", downState);
setKeysDownState(downState);
setkeyPressReportApiAvailable(true); // if they returned a keyDownState, we know they also support keyPressReport
}
setNeedKeyDownState(false);
});