feat: use clientSide macro if backend doesn't support macros

This commit is contained in:
Siyuan Miao 2025-09-18 11:00:42 +02:00
parent 0b83dfc230
commit 455ab1bf02
5 changed files with 163 additions and 59 deletions

View File

@ -6,7 +6,7 @@ import { LuCornerDownLeft } from "react-icons/lu";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
import { useHidStore, useSettingsStore, useUiStore } from "@/hooks/stores"; import { useHidStore, useSettingsStore, useUiStore } from "@/hooks/stores";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import useKeyboard from "@/hooks/useKeyboard"; import useKeyboard, { type MacroStep } from "@/hooks/useKeyboard";
import useKeyboardLayout from "@/hooks/useKeyboardLayout"; import useKeyboardLayout from "@/hooks/useKeyboardLayout";
import notifications from "@/notifications"; import notifications from "@/notifications";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
@ -58,11 +58,7 @@ export default function PasteModal() {
const text = TextAreaRef.current.value; const text = TextAreaRef.current.value;
try { try {
const macroSteps: { const macroSteps: MacroStep[] = [];
keys: string[] | null;
modifiers: string[] | null;
delay: number;
}[] = [];
for (const char of text) { for (const char of text) {
const keyprops = selectedKeyboard.chars[char]; const keyprops = selectedKeyboard.chars[char];

View File

@ -105,6 +105,9 @@ export interface RTCState {
setRpcDataChannel: (channel: RTCDataChannel) => void; setRpcDataChannel: (channel: RTCDataChannel) => void;
rpcDataChannel: RTCDataChannel | null; rpcDataChannel: RTCDataChannel | null;
hidRpcDisabled: boolean;
setHidRpcDisabled: (disabled: boolean) => void;
rpcHidProtocolVersion: number | null; rpcHidProtocolVersion: number | null;
setRpcHidProtocolVersion: (version: number) => void; setRpcHidProtocolVersion: (version: number) => void;
@ -157,6 +160,9 @@ export const useRTCStore = create<RTCState>(set => ({
rpcDataChannel: null, rpcDataChannel: null,
setRpcDataChannel: (channel: RTCDataChannel) => set({ rpcDataChannel: channel }), setRpcDataChannel: (channel: RTCDataChannel) => set({ rpcDataChannel: channel }),
hidRpcDisabled: false,
setHidRpcDisabled: (disabled: boolean) => set({ hidRpcDisabled: disabled }),
rpcHidProtocolVersion: null, rpcHidProtocolVersion: null,
setRpcHidProtocolVersion: (version: number) => set({ rpcHidProtocolVersion: version }), setRpcHidProtocolVersion: (version: number) => set({ rpcHidProtocolVersion: version }),

View File

@ -17,19 +17,23 @@ import {
} from "./hidRpc"; } from "./hidRpc";
export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) { export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) {
const { rpcHidChannel, setRpcHidProtocolVersion, rpcHidProtocolVersion } = useRTCStore(); const { rpcHidChannel, setRpcHidProtocolVersion, rpcHidProtocolVersion, hidRpcDisabled } = useRTCStore();
const rpcHidReady = useMemo(() => { const rpcHidReady = useMemo(() => {
if (hidRpcDisabled) return false;
return rpcHidChannel?.readyState === "open" && rpcHidProtocolVersion !== null; return rpcHidChannel?.readyState === "open" && rpcHidProtocolVersion !== null;
}, [rpcHidChannel, rpcHidProtocolVersion]); }, [rpcHidChannel, rpcHidProtocolVersion, hidRpcDisabled]);
const rpcHidStatus = useMemo(() => { const rpcHidStatus = useMemo(() => {
if (hidRpcDisabled) return "disabled";
if (!rpcHidChannel) return "N/A"; if (!rpcHidChannel) return "N/A";
if (rpcHidChannel.readyState !== "open") return rpcHidChannel.readyState; if (rpcHidChannel.readyState !== "open") return rpcHidChannel.readyState;
if (!rpcHidProtocolVersion) return "handshaking"; if (!rpcHidProtocolVersion) return "handshaking";
return `ready (v${rpcHidProtocolVersion})`; return `ready (v${rpcHidProtocolVersion})`;
}, [rpcHidChannel, rpcHidProtocolVersion]); }, [rpcHidChannel, rpcHidProtocolVersion, hidRpcDisabled]);
const sendMessage = useCallback((message: RpcMessage, ignoreHandshakeState = false) => { const sendMessage = useCallback((message: RpcMessage, ignoreHandshakeState = false) => {
if (hidRpcDisabled) return;
if (rpcHidChannel?.readyState !== "open") return; if (rpcHidChannel?.readyState !== "open") return;
if (!rpcHidReady && !ignoreHandshakeState) return; if (!rpcHidReady && !ignoreHandshakeState) return;
@ -42,7 +46,7 @@ export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) {
if (!data) return; if (!data) return;
rpcHidChannel?.send(data as unknown as ArrayBuffer); rpcHidChannel?.send(data as unknown as ArrayBuffer);
}, [rpcHidChannel, rpcHidReady]); }, [rpcHidChannel, rpcHidReady, hidRpcDisabled]);
const reportKeyboardEvent = useCallback( const reportKeyboardEvent = useCallback(
(keys: number[], modifier: number) => { (keys: number[], modifier: number) => {
@ -87,13 +91,16 @@ export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) {
); );
const sendHandshake = useCallback(() => { const sendHandshake = useCallback(() => {
if (hidRpcDisabled) return;
if (rpcHidProtocolVersion) return; if (rpcHidProtocolVersion) return;
if (!rpcHidChannel) return; if (!rpcHidChannel) return;
sendMessage(new HandshakeMessage(HID_RPC_VERSION), true); sendMessage(new HandshakeMessage(HID_RPC_VERSION), true);
}, [rpcHidChannel, rpcHidProtocolVersion, sendMessage]); }, [rpcHidChannel, rpcHidProtocolVersion, sendMessage, hidRpcDisabled]);
const handleHandshake = useCallback((message: HandshakeMessage) => { const handleHandshake = useCallback((message: HandshakeMessage) => {
if (hidRpcDisabled) return;
if (!message.version) { if (!message.version) {
console.error("Received handshake message without version", message); console.error("Received handshake message without version", message);
return; return;
@ -108,10 +115,11 @@ export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) {
} }
setRpcHidProtocolVersion(message.version); setRpcHidProtocolVersion(message.version);
}, [setRpcHidProtocolVersion]); }, [setRpcHidProtocolVersion, hidRpcDisabled]);
useEffect(() => { useEffect(() => {
if (!rpcHidChannel) return; if (!rpcHidChannel) return;
if (hidRpcDisabled) return;
// send handshake message // send handshake message
sendHandshake(); sendHandshake();
@ -153,6 +161,7 @@ export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) {
setRpcHidProtocolVersion, setRpcHidProtocolVersion,
sendHandshake, sendHandshake,
handleHandshake, handleHandshake,
hidRpcDisabled,
], ],
); );

View File

@ -1,4 +1,4 @@
import { useCallback, useMemo } from "react"; import { useCallback, useRef } from "react";
import { import {
hidErrorRollOver, hidErrorRollOver,
@ -9,13 +9,40 @@ import {
} from "@/hooks/stores"; } from "@/hooks/stores";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { useHidRpc } from "@/hooks/useHidRpc"; import { useHidRpc } from "@/hooks/useHidRpc";
import { KeyboardLedStateMessage, KeyboardMacroStateReportMessage, KeyboardMacroStep, KeysDownStateMessage } from "@/hooks/hidRpc"; import {
KeyboardLedStateMessage,
KeyboardMacroStateReportMessage,
KeyboardMacroStep,
KeysDownStateMessage,
} from "@/hooks/hidRpc";
import { hidKeyToModifierMask, keys, modifiers } from "@/keyboardMappings"; import { hidKeyToModifierMask, keys, modifiers } from "@/keyboardMappings";
const MACRO_RESET_KEYBOARD_STATE = {
keys: new Array(hidKeyBufferSize).fill(0),
modifier: 0,
delay: 0,
};
export interface MacroStep {
keys: string[] | null;
modifiers: string[] | null;
delay: number;
}
export type MacroSteps = MacroStep[];
const sleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms));
export default function useKeyboard() { export default function useKeyboard() {
const { send } = useJsonRpc(); const { send } = useJsonRpc();
const { rpcDataChannel } = useRTCStore(); const { rpcDataChannel } = useRTCStore();
const { keysDownState, setKeysDownState, setKeyboardLedState, setPasteModeEnabled } = useHidStore(); const { keysDownState, setKeysDownState, setKeyboardLedState, setPasteModeEnabled } =
useHidStore();
const abortController = useRef<AbortController | null>(null);
const setAbortController = useCallback((ac: AbortController | null) => {
abortController.current = ac;
}, []);
// INTRODUCTION: The earlier version of the JetKVM device shipped with all keyboard state // 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 // being tracked on the browser/client-side. When adding the keyPressReport API to the
@ -46,56 +73,58 @@ export default function useKeyboard() {
case KeyboardMacroStateReportMessage: case KeyboardMacroStateReportMessage:
if (!(message as KeyboardMacroStateReportMessage).isPaste) break; if (!(message as KeyboardMacroStateReportMessage).isPaste) break;
setPasteModeEnabled((message as KeyboardMacroStateReportMessage).state); setPasteModeEnabled((message as KeyboardMacroStateReportMessage).state);
break; break;
default: default:
break; break;
} }
}); });
// sendKeyboardEvent is used to send the full keyboard state to the device for macro handling const handleLegacyKeyboardReport = useCallback(
// and resetting keyboard state. It sends the keys currently pressed and the modifier state. async (keys: number[], modifier: number) => {
// The device will respond with the keysDownState if it supports the keyPressReport API send("keyboardReport", { keys, modifier }, (resp: JsonRpcResponse) => {
// or just accept the state if it does not support (returning no result) if ("error" in resp) {
const sendKeyboardEvent = useCallback( console.error(`Failed to send keyboard report ${keys} ${modifier}`, resp.error);
async (state: KeysDownState) => { }
if (rpcDataChannel?.readyState !== "open" && !rpcHidReady) return;
console.debug( // On older backends, we need to set the keysDownState manually since without the hidRpc API, the state doesn't trickle down from the backend
`Send keyboardReport keys: ${state.keys}, modifier: ${state.modifier}`, setKeysDownState(MACRO_RESET_KEYBOARD_STATE);
); });
},
[send, setKeysDownState],
);
const sendKeystrokeLegacy = useCallback(async (keys: number[], modifier: number, ac?: AbortController) => {
return await new Promise<void>((resolve, reject) => {
const abortListener = () => {
reject(new Error("Keyboard report aborted"));
};
if (rpcHidReady) { ac?.signal?.addEventListener("abort", abortListener);
console.debug("Sending keyboard report via HidRPC");
sendKeyboardEventHidRpc(state.keys, state.modifier);
return;
}
send( send(
"keyboardReport", "keyboardReport",
{ keys: state.keys, modifier: state.modifier }, { keys, modifier },
(resp: JsonRpcResponse) => { params => {
if ("error" in resp) { if ("error" in params) return reject(params.error);
console.error(`Failed to send keyboard report ${state}`, resp.error); resolve();
}
}, },
); );
}, });
[rpcDataChannel?.readyState, rpcHidReady, send, sendKeyboardEventHidRpc], }, [send]);
);
const MACRO_RESET_KEYBOARD_STATE = useMemo(() => ({
keys: new Array(hidKeyBufferSize).fill(0),
modifier: 0,
delay: 0,
}), []);
// resetKeyboardState is used to reset the keyboard state to no keys pressed and no modifiers. // 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 // This is useful for macros and when the browser loses focus to ensure that the keyboard state
// is clean. // is clean.
const resetKeyboardState = useCallback(async () => { const resetKeyboardState = useCallback(async () => {
// Reset the keys buffer to zeros and the modifier state to zero // Reset the keys buffer to zeros and the modifier state to zero
sendKeyboardEvent(MACRO_RESET_KEYBOARD_STATE); const { keys, modifier } = MACRO_RESET_KEYBOARD_STATE;
}, [sendKeyboardEvent, MACRO_RESET_KEYBOARD_STATE]); if (rpcHidReady) {
sendKeyboardEventHidRpc(keys, modifier);
} else {
// Older backends don't support the hidRpc API, so we send the full reset state
handleLegacyKeyboardReport(keys, modifier);
}
}, [rpcHidReady, sendKeyboardEventHidRpc, handleLegacyKeyboardReport]);
// executeMacro is used to execute a macro consisting of multiple steps. // executeMacro is used to execute a macro consisting of multiple steps.
// Each step can have multiple keys, multiple modifiers and a delay. // Each step can have multiple keys, multiple modifiers and a delay.
@ -103,9 +132,7 @@ export default function useKeyboard() {
// After the delay, the keys and modifiers are released and the next step is executed. // 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. // 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. // A small pause is added between steps to ensure that the device can process the events.
const executeMacro = async ( const executeMacroRemote = useCallback(async (steps: MacroSteps) => {
steps: { keys: string[] | null; modifiers: string[] | null; delay: number }[],
) => {
const macro: KeyboardMacroStep[] = []; const macro: KeyboardMacroStep[] = [];
for (const [_, step] of steps.entries()) { for (const [_, step] of steps.entries()) {
@ -122,12 +149,73 @@ export default function useKeyboard() {
} }
sendKeyboardMacroEventHidRpc(macro); sendKeyboardMacroEventHidRpc(macro);
}; }, [sendKeyboardMacroEventHidRpc]);
const executeMacroClientSide = useCallback(async (steps: MacroSteps) => {
const promises: (() => Promise<void>)[] = [];
const ac = new AbortController();
setAbortController(ac);
for (const [_, 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) {
promises.push(() => sendKeystrokeLegacy(keyValues, modifierMask, ac));
promises.push(() => resetKeyboardState());
promises.push(() => sleep(step.delay || 100));
}
}
const runAll = async () => {
for (const promise of promises) {
// Check if we've been aborted before executing each promise
if (ac.signal.aborted) {
throw new Error("Macro execution aborted");
}
await promise();
}
}
return await new Promise<void>((resolve, reject) => {
// Set up abort listener
const abortListener = () => {
reject(new Error("Macro execution aborted"));
};
ac.signal.addEventListener("abort", abortListener);
runAll()
.then(() => {
ac.signal.removeEventListener("abort", abortListener);
resolve();
})
.catch((error) => {
ac.signal.removeEventListener("abort", abortListener);
reject(error);
});
});
}, [sendKeystrokeLegacy, resetKeyboardState, setAbortController]);
const executeMacro = useCallback(async (steps: MacroSteps) => {
if (rpcHidReady) {
return executeMacroRemote(steps);
}
return executeMacroClientSide(steps);
}, [rpcHidReady, executeMacroRemote, executeMacroClientSide]);
const cancelExecuteMacro = useCallback(async () => { const cancelExecuteMacro = useCallback(async () => {
if (abortController.current) {
abortController.current.abort();
}
if (!rpcHidReady) return; if (!rpcHidReady) return;
// older versions don't support this API,
// and all paste actions are pure-frontend,
// we don't need to cancel it actually
cancelOngoingKeyboardMacroHidRpc(); cancelOngoingKeyboardMacroHidRpc();
}, [rpcHidReady, cancelOngoingKeyboardMacroHidRpc]); }, [rpcHidReady, cancelOngoingKeyboardMacroHidRpc, abortController]);
// handleKeyPress is used to handle a key press or release event. // handleKeyPress is used to handle a key press or release event.
// This function handle both key press and key release events. // This function handle both key press and key release events.
@ -149,13 +237,16 @@ export default function useKeyboard() {
// in client/browser-side code using simulateDeviceSideKeyHandlingForLegacyDevices. // in client/browser-side code using simulateDeviceSideKeyHandlingForLegacyDevices.
sendKeypressEventHidRpc(key, press); sendKeypressEventHidRpc(key, press);
} else { } else {
// if the keyPress api is not available, we need to handle the key locally // Older backends don't support the hidRpc API, so we need:
// 1. Calculate the state
// 2. Send the newly calculated state to the device
const downState = simulateDeviceSideKeyHandlingForLegacyDevices( const downState = simulateDeviceSideKeyHandlingForLegacyDevices(
keysDownState, keysDownState,
key, key,
press, press,
); );
sendKeyboardEvent(downState); // then we send the full state
handleLegacyKeyboardReport(downState.keys, downState.modifier);
// if we just sent ErrorRollOver, reset to empty state // if we just sent ErrorRollOver, reset to empty state
if (downState.keys[0] === hidErrorRollOver) { if (downState.keys[0] === hidErrorRollOver) {
@ -164,12 +255,12 @@ export default function useKeyboard() {
} }
}, },
[ [
rpcHidReady,
keysDownState,
resetKeyboardState,
rpcDataChannel?.readyState, rpcDataChannel?.readyState,
sendKeyboardEvent, rpcHidReady,
sendKeypressEventHidRpc, sendKeypressEventHidRpc,
keysDownState,
handleLegacyKeyboardReport,
resetKeyboardState,
], ],
); );

View File

@ -583,6 +583,7 @@ export default function KvmIdRoute() {
keyboardLedState, setKeyboardLedState, keyboardLedState, setKeyboardLedState,
keysDownState, setKeysDownState, setUsbState, keysDownState, setKeysDownState, setUsbState,
} = useHidStore(); } = useHidStore();
const setHidRpcDisabled = useRTCStore(state => state.setHidRpcDisabled);
const [hasUpdated, setHasUpdated] = useState(false); const [hasUpdated, setHasUpdated] = useState(false);
const { navigateTo } = useDeviceUiNavigation(); const { navigateTo } = useDeviceUiNavigation();
@ -695,6 +696,7 @@ export default function KvmIdRoute() {
if (resp.error.code === -32601) { if (resp.error.code === -32601) {
// if we don't support key down state, we know key press is also not available // if we don't support key down state, we know key press is also not available
console.warn("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);
setHidRpcDisabled(true);
} else { } else {
console.error("Failed to get key down state", resp.error); console.error("Failed to get key down state", resp.error);
} }
@ -705,7 +707,7 @@ export default function KvmIdRoute() {
} }
setNeedKeyDownState(false); setNeedKeyDownState(false);
}); });
}, [keysDownState, needKeyDownState, rpcDataChannel?.readyState, send, setKeysDownState]); }, [keysDownState, needKeyDownState, rpcDataChannel?.readyState, send, setKeysDownState, setHidRpcDisabled]);
// When the update is successful, we need to refresh the client javascript and show a success modal // When the update is successful, we need to refresh the client javascript and show a success modal
useEffect(() => { useEffect(() => {