mirror of https://github.com/jetkvm/kvm.git
219 lines
11 KiB
TypeScript
219 lines
11 KiB
TypeScript
import { useCallback } from "react";
|
|
|
|
import { KeysDownState, useHidStore, useRTCStore, hidKeyBufferSize, hidErrorRollOver } from "@/hooks/stores";
|
|
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
|
import { hidKeyToModifierMask, keys, modifiers } from "@/keyboardMappings";
|
|
|
|
export default function useKeyboard() {
|
|
const { send } = useJsonRpc();
|
|
const { rpcDataChannel } = useRTCStore();
|
|
const { keysDownState, setKeysDownState } = useHidStore();
|
|
|
|
// INTRODUCTION: The earlier version of the JetKVM device shipped with all keyboard state
|
|
// being tracked on the browser/client-side. When adding the keyPressReport API to the
|
|
// device-side code, we have to still support the situation where the browser/client-side code
|
|
// is running on the cloud against a device that has not been updated yet and thus does not
|
|
// support the keyPressReport API. In that case, we need to handle the key presses locally
|
|
// and send the full state to the device, so it can behave like a real USB HID keyboard.
|
|
// This flag indicates whether the keyPressReport API is available on the device which is
|
|
// dynamically set when the device responds to the first key press event or reports its
|
|
// keysDownState when queried since the keyPressReport was introduced together with the
|
|
// getKeysDownState API.
|
|
const { keyPressReportApiAvailable, setkeyPressReportApiAvailable} = useHidStore();
|
|
|
|
// sendKeyboardEvent is used to send the full keyboard state to the device for macro handling
|
|
// and resetting keyboard state. It sends the keys currently pressed and the modifier state.
|
|
// 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(
|
|
async (state: KeysDownState) => {
|
|
if (rpcDataChannel?.readyState !== "open") return;
|
|
|
|
console.debug(`Send keyboardReport keys: ${state.keys}, modifier: ${state.modifier}`);
|
|
send("keyboardReport", { keys: state.keys, modifier: state.modifier }, (resp: JsonRpcResponse) => {
|
|
if ("error" in resp) {
|
|
console.error(`Failed to send keyboard report ${state}`, resp.error);
|
|
} else {
|
|
// If the device supports keyPressReport API, it will (also) return the keysDownState when we send
|
|
// the keyboardReport
|
|
const keysDownState = resp.result as KeysDownState;
|
|
|
|
if (keysDownState) {
|
|
setKeysDownState(keysDownState); // treat the response as the canonical state
|
|
setkeyPressReportApiAvailable(true); // if they returned a keysDownState, we ALSO know they also support keyPressReport
|
|
} else {
|
|
// older devices versions do not return the keyDownState
|
|
// so we just pretend they accepted what we sent
|
|
setKeysDownState(state);
|
|
setkeyPressReportApiAvailable(false); // we ALSO know they do not support keyPressReport
|
|
}
|
|
}
|
|
});
|
|
},
|
|
[rpcDataChannel?.readyState, send, setKeysDownState, setkeyPressReportApiAvailable],
|
|
);
|
|
|
|
// sendKeypressEvent is used to send a single key press/release event to the device.
|
|
// It sends the key and whether it is pressed or released.
|
|
// Older device version will not understand this request and will respond with
|
|
// an error with code -32601, which means that the RPC method name was not recognized.
|
|
// In that case we will switch to local key handling and update the keysDownState
|
|
// in client/browser-side code using simulateDeviceSideKeyHandlingForLegacyDevices.
|
|
const sendKeypressEvent = useCallback(
|
|
async (key: number, press: boolean) => {
|
|
if (rpcDataChannel?.readyState !== "open") return;
|
|
|
|
console.debug(`Send keypressEvent key: ${key}, press: ${press}`);
|
|
send("keypressReport", { key, press }, (resp: JsonRpcResponse) => {
|
|
if ("error" in resp) {
|
|
// -32601 means the method is not supported because the device is running an older version
|
|
if (resp.error.code === -32601) {
|
|
console.error("Legacy device does not support keypressReport API, switching to local key down state handling", resp.error);
|
|
setkeyPressReportApiAvailable(false);
|
|
} else {
|
|
console.error(`Failed to send key ${key} press: ${press}`, resp.error);
|
|
}
|
|
} else {
|
|
const keysDownState = resp.result as KeysDownState;
|
|
|
|
if (keysDownState) {
|
|
setKeysDownState(keysDownState);
|
|
// we don't need to set keyPressReportApiAvailable here, because it's already true or we never landed here
|
|
}
|
|
}
|
|
});
|
|
},
|
|
[rpcDataChannel?.readyState, send, setkeyPressReportApiAvailable, setKeysDownState],
|
|
);
|
|
|
|
// 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(
|
|
async () => {
|
|
// 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.
|
|
// The keys and modifiers are pressed together and held for the delay duration.
|
|
// After the delay, the keys and modifiers are released and the next step is executed.
|
|
// If a step has no keys or modifiers, it is treated as a delay-only step.
|
|
// A small pause is added between steps to ensure that the device can process the events.
|
|
const executeMacro = async (steps: { keys: string[] | null; modifiers: string[] | null; delay: number }[]) => {
|
|
for (const [index, step] of steps.entries()) {
|
|
const keyValues = (step.keys || []).map(key => keys[key]).filter(Boolean);
|
|
const modifierMask: number = (step.modifiers || []).map(mod => modifiers[mod]).reduce((acc, val) => acc + val, 0);
|
|
|
|
// If the step has keys and/or modifiers, press them and hold for the delay
|
|
if (keyValues.length > 0 || modifierMask > 0) {
|
|
sendKeyboardEvent({ keys: keyValues, modifier: modifierMask });
|
|
await new Promise(resolve => setTimeout(resolve, step.delay || 50));
|
|
|
|
resetKeyboardState();
|
|
} else {
|
|
// This is a delay-only step, just wait for the delay amount
|
|
await new Promise(resolve => setTimeout(resolve, step.delay || 50));
|
|
}
|
|
|
|
// Add a small pause between steps if not the last step
|
|
if (index < steps.length - 1) {
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
}
|
|
}
|
|
};
|
|
|
|
// handleKeyPress is used to handle a key press or release event.
|
|
// This function handle both key press and key release events.
|
|
// It checks if the keyPressReport API is available and sends the key press event.
|
|
// If the keyPressReport API is not available, it simulates the device-side key
|
|
// handling for legacy devices and updates the keysDownState accordingly.
|
|
// It then sends the full keyboard state to the device.
|
|
const handleKeyPress = useCallback(
|
|
async (key: number, press: boolean) => {
|
|
if (rpcDataChannel?.readyState !== "open") return;
|
|
if ((key || 0) === 0) return; // ignore zero key presses (they are bad mappings)
|
|
|
|
if (keyPressReportApiAvailable) {
|
|
// if the keyPress api is available, we can just send the key press event
|
|
sendKeypressEvent(key, press);
|
|
} else {
|
|
// 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, resetKeyboardState, rpcDataChannel?.readyState, sendKeyboardEvent, sendKeypressEvent],
|
|
);
|
|
|
|
// IMPORTANT: See the keyPressReportApiAvailable comment above for the reason this exists
|
|
function simulateDeviceSideKeyHandlingForLegacyDevices(state: KeysDownState, key: number, press: boolean): KeysDownState {
|
|
// IMPORTANT: This code parallels the logic in the kernel's hid-gadget driver
|
|
// 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.
|
|
let modifiers = state.modifier;
|
|
const keys = state.keys;
|
|
const modifierMask = hidKeyToModifierMask[key] || 0;
|
|
|
|
if (modifierMask !== 0) {
|
|
// If the key is a modifier key, we update the keyboardModifier state
|
|
// 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.
|
|
if (press) {
|
|
modifiers |= modifierMask;
|
|
} else {
|
|
modifiers &= ~modifierMask;
|
|
}
|
|
} else {
|
|
// 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; 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 (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) {
|
|
keys.splice(i, 1);
|
|
keys.push(0); // add a zero at the end
|
|
}
|
|
}
|
|
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 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`)
|
|
}
|
|
}
|
|
}
|
|
return { modifier: modifiers, keys };
|
|
}
|
|
|
|
return { handleKeyPress, resetKeyboardState, executeMacro };
|
|
}
|