Compare commits

...

6 Commits

Author SHA1 Message Date
Siyuan Miao 78e8a3570b chore: add max length for paste text 2025-09-18 12:51:05 +02:00
Siyuan Miao 153f458beb fix: send duplicated keyDownState 2025-09-18 12:46:35 +02:00
Siyuan Miao 83a0dbe628 refactor 2025-09-18 12:45:20 +02:00
Siyuan Miao b5978016af minor issues 2025-09-18 12:16:51 +02:00
Adam Shiervani ade9a4961a fix: update keysDownState handling 2025-09-18 11:45:54 +02:00
Siyuan Miao 455ab1bf02 feat: use clientSide macro if backend doesn't support macros 2025-09-18 11:00:42 +02:00
13 changed files with 239 additions and 128 deletions

View File

@ -476,7 +476,7 @@ func handleSessionRequest(
cloudLogger.Info().Interface("session", session).Msg("new session accepted")
cloudLogger.Trace().Interface("session", session).Msg("new session accepted")
// Cancel any ongoing keyboard report multi when session changes
// Cancel any ongoing keyboard macro when session changes
cancelKeyboardMacro()
currentSession = session

View File

@ -37,7 +37,7 @@ func handleHidRPCMessage(message hidrpc.Message, session *Session) {
logger.Warn().Err(err).Msg("failed to get keyboard macro report")
return
}
_, rpcErr = rpcExecuteKeyboardMacro(keyboardMacroReport.Macro)
_, rpcErr = rpcExecuteKeyboardMacro(keyboardMacroReport.Steps)
case hidrpc.TypeCancelKeyboardMacroReport:
rpcCancelKeyboardMacro()
return
@ -140,7 +140,7 @@ func reportHidRPC(params any, session *Session) {
message, err = hidrpc.NewKeyboardLedMessage(params).Marshal()
case usbgadget.KeysDownState:
message, err = hidrpc.NewKeydownStateMessage(params).Marshal()
case hidrpc.KeyboardMacroStateReport:
case hidrpc.KeyboardMacroState:
message, err = hidrpc.NewKeyboardMacroStateMessage(params.State, params.IsPaste).Marshal()
default:
err = fmt.Errorf("unknown HID RPC message type: %T", params)
@ -179,7 +179,7 @@ func (s *Session) reportHidRPCKeysDownState(state usbgadget.KeysDownState) {
reportHidRPC(state, s)
}
func (s *Session) reportHidRPCKeyboardMacroState(state hidrpc.KeyboardMacroStateReport) {
func (s *Session) reportHidRPCKeyboardMacroState(state hidrpc.KeyboardMacroState) {
if !s.hidRPCAvailable {
writeJSONRPCEvent("keyboardMacroState", state, s)
}

View File

@ -20,7 +20,7 @@ const (
TypeCancelKeyboardMacroReport MessageType = 0x08
TypeKeyboardLedState MessageType = 0x32
TypeKeydownState MessageType = 0x33
TypeKeyboardMacroStateReport MessageType = 0x34
TypeKeyboardMacroState MessageType = 0x34
)
const (
@ -32,7 +32,7 @@ func GetQueueIndex(messageType MessageType) int {
switch messageType {
case TypeHandshake:
return 0
case TypeKeyboardReport, TypeKeypressReport, TypeKeyboardMacroReport, TypeKeyboardLedState, TypeKeydownState, TypeKeyboardMacroStateReport:
case TypeKeyboardReport, TypeKeypressReport, TypeKeyboardMacroReport, TypeKeyboardLedState, TypeKeydownState, TypeKeyboardMacroState:
return 1
case TypePointerReport, TypeMouseReport, TypeWheelReport:
return 2
@ -116,7 +116,7 @@ func NewKeyboardMacroStateMessage(state bool, isPaste bool) *Message {
}
return &Message{
t: TypeKeyboardMacroStateReport,
t: TypeKeyboardMacroState,
d: data,
}
}

View File

@ -91,17 +91,20 @@ func (m *Message) KeyboardReport() (KeyboardReport, error) {
}
// Macro ..
type KeyboardMacro struct {
type KeyboardMacroStep struct {
Modifier byte // 1 byte
Keys []byte // 6 bytes, to make things easier, the keys length is fixed to 6
Keys []byte // 6 bytes: hidKeyBufferSize
Delay uint16 // 2 bytes
}
type KeyboardMacroReport struct {
IsPaste bool
Length uint32
Macro []KeyboardMacro
IsPaste bool
StepCount uint32
Steps []KeyboardMacroStep
}
// HidKeyBufferSize is the size of the keys buffer in the keyboard report.
const HidKeyBufferSize = 6
// KeyboardMacroReport returns the keyboard macro report from the message.
func (m *Message) KeyboardMacroReport() (KeyboardMacroReport, error) {
if m.t != TypeKeyboardMacroReport {
@ -109,29 +112,30 @@ func (m *Message) KeyboardMacroReport() (KeyboardMacroReport, error) {
}
isPaste := m.d[0] == uint8(1)
length := binary.BigEndian.Uint32(m.d[1:5])
stepCount := binary.BigEndian.Uint32(m.d[1:5])
// check total length
expectedLength := int(length)*9 + 5
expectedLength := int(stepCount)*9 + 5
if len(m.d) != expectedLength {
return KeyboardMacroReport{}, fmt.Errorf("invalid length: %d, expected: %d", len(m.d), expectedLength)
}
macro := make([]KeyboardMacro, 0, int(length))
for i := 0; i < int(length); i++ {
offset := 5 + i*9
macro = append(macro, KeyboardMacro{
steps := make([]KeyboardMacroStep, 0, int(stepCount))
offset := 5
for i := 0; i < int(stepCount); i++ {
steps = append(steps, KeyboardMacroStep{
Modifier: m.d[offset],
Keys: m.d[offset+1 : offset+7],
Delay: binary.BigEndian.Uint16(m.d[offset+7 : offset+9]),
})
offset += 1 + HidKeyBufferSize + 2
}
return KeyboardMacroReport{
IsPaste: isPaste,
Macro: macro,
Length: length,
IsPaste: isPaste,
Steps: steps,
StepCount: stepCount,
}, nil
}
@ -183,18 +187,18 @@ func (m *Message) MouseReport() (MouseReport, error) {
}, nil
}
type KeyboardMacroStateReport struct {
type KeyboardMacroState struct {
State bool
IsPaste bool
}
// KeyboardMacroStateReport returns the keyboard macro state report from the message.
func (m *Message) KeyboardMacroStateReport() (KeyboardMacroStateReport, error) {
if m.t != TypeKeyboardMacroStateReport {
return KeyboardMacroStateReport{}, fmt.Errorf("invalid message type: %d", m.t)
// KeyboardMacroState returns the keyboard macro state report from the message.
func (m *Message) KeyboardMacroState() (KeyboardMacroState, error) {
if m.t != TypeKeyboardMacroState {
return KeyboardMacroState{}, fmt.Errorf("invalid message type: %d", m.t)
}
return KeyboardMacroStateReport{
return KeyboardMacroState{
State: m.d[0] == uint8(1),
IsPaste: m.d[1] == uint8(1),
}, nil

View File

@ -1076,13 +1076,13 @@ func setKeyboardMacroCancel(cancel context.CancelFunc) {
keyboardMacroCancel = cancel
}
func rpcExecuteKeyboardMacro(macro []hidrpc.KeyboardMacro) (usbgadget.KeysDownState, error) {
func rpcExecuteKeyboardMacro(macro []hidrpc.KeyboardMacroStep) (usbgadget.KeysDownState, error) {
cancelKeyboardMacro()
ctx, cancel := context.WithCancel(context.Background())
setKeyboardMacroCancel(cancel)
s := hidrpc.KeyboardMacroStateReport{
s := hidrpc.KeyboardMacroState{
State: true,
IsPaste: true,
}
@ -1107,13 +1107,13 @@ func rpcCancelKeyboardMacro() {
cancelKeyboardMacro()
}
var keyboardClearStateKeys = make([]byte, 6)
var keyboardClearStateKeys = make([]byte, hidrpc.HidKeyBufferSize)
func isClearKeyStep(step hidrpc.KeyboardMacro) bool {
func isClearKeyStep(step hidrpc.KeyboardMacroStep) bool {
return step.Modifier == 0 && bytes.Equal(step.Keys, keyboardClearStateKeys)
}
func rpcDoExecuteKeyboardMacro(ctx context.Context, macro []hidrpc.KeyboardMacro) (usbgadget.KeysDownState, error) {
func rpcDoExecuteKeyboardMacro(ctx context.Context, macro []hidrpc.KeyboardMacroStep) (usbgadget.KeysDownState, error) {
var last usbgadget.KeysDownState
var err error
@ -1143,7 +1143,6 @@ func rpcDoExecuteKeyboardMacro(ctx context.Context, macro []hidrpc.KeyboardMacro
if err != nil {
logger.Warn().Err(err).Msg("failed to reset keyboard state")
}
gadget.UpdateKeysDown(0, keyboardClearStateKeys)
logger.Debug().Int("step", i).Msg("Keyboard macro cancelled during sleep")
return last, ctx.Err()

View File

@ -27,7 +27,7 @@ export default function InfoBar() {
const { rpcDataChannel } = useRTCStore();
const { debugMode, mouseMode, showPressedKeys } = useSettingsStore();
const { isPasteModeEnabled } = useHidStore();
const { isPasteInProgress } = useHidStore();
useEffect(() => {
if (!rpcDataChannel) return;
@ -109,7 +109,7 @@ export default function InfoBar() {
<span className="text-xs">{rpcHidStatus}</span>
</div>
)}
{isPasteModeEnabled && (
{isPasteInProgress && (
<div className="flex w-[156px] items-center gap-x-1">
<span className="text-xs font-semibold">Paste Mode:</span>
<span className="text-xs">Enabled</span>

View File

@ -6,7 +6,7 @@ import { LuCornerDownLeft } from "react-icons/lu";
import { cx } from "@/cva.config";
import { useHidStore, useSettingsStore, useUiStore } from "@/hooks/stores";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import useKeyboard from "@/hooks/useKeyboard";
import useKeyboard, { type MacroStep } from "@/hooks/useKeyboard";
import useKeyboardLayout from "@/hooks/useKeyboardLayout";
import notifications from "@/notifications";
import { Button } from "@components/Button";
@ -15,9 +15,12 @@ import { InputFieldWithLabel } from "@components/InputField";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { TextAreaWithLabel } from "@components/TextArea";
// uint32 max value / 4
const pasteMaxLength = 1073741824;
export default function PasteModal() {
const TextAreaRef = useRef<HTMLTextAreaElement>(null);
const { isPasteModeEnabled } = useHidStore();
const { isPasteInProgress } = useHidStore();
const { setDisableVideoFocusTrap } = useUiStore();
const { send } = useJsonRpc();
@ -58,11 +61,7 @@ export default function PasteModal() {
const text = TextAreaRef.current.value;
try {
const macroSteps: {
keys: string[] | null;
modifiers: string[] | null;
delay: number;
}[] = [];
const macroSteps: MacroStep[] = [];
for (const char of text) {
const keyprops = selectedKeyboard.chars[char];
@ -137,13 +136,15 @@ export default function PasteModal() {
<div
className="w-full"
onKeyUp={e => e.stopPropagation()}
onKeyDown={e => e.stopPropagation()}
onKeyDown={e => e.stopPropagation()} onKeyDownCapture={e => e.stopPropagation()}
onKeyUpCapture={e => e.stopPropagation()}
>
<TextAreaWithLabel
ref={TextAreaRef}
label="Paste from host"
rows={4}
onKeyUp={e => e.stopPropagation()}
maxLength={pasteMaxLength}
onKeyDown={e => {
e.stopPropagation();
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
@ -231,7 +232,7 @@ export default function PasteModal() {
size="SM"
theme="primary"
text="Confirm Paste"
disabled={isPasteModeEnabled}
disabled={isPasteInProgress}
onClick={onConfirmPaste}
LeadingIcon={LuCornerDownLeft}
/>

View File

@ -1,4 +1,4 @@
import { KeyboardLedState, KeysDownState } from "./stores";
import { hidKeyBufferSize, KeyboardLedState, KeysDownState } from "./stores";
export const HID_RPC_MESSAGE_TYPES = {
Handshake: 0x01,
@ -11,7 +11,7 @@ export const HID_RPC_MESSAGE_TYPES = {
CancelKeyboardMacroReport: 0x08,
KeyboardLedState: 0x32,
KeysDownState: 0x33,
KeyboardMacroStateReport: 0x34,
KeyboardMacroState: 0x34,
}
export type HidRpcMessageType = typeof HID_RPC_MESSAGE_TYPES[keyof typeof HID_RPC_MESSAGE_TYPES];
@ -31,7 +31,7 @@ const fromInt32toUint8 = (n: number) => {
(n >> 24) & 0xFF,
(n >> 16) & 0xFF,
(n >> 8) & 0xFF,
(n >> 0) & 0xFF,
n & 0xFF,
]);
};
@ -42,7 +42,7 @@ const fromUint16toUint8 = (n: number) => {
return new Uint8Array([
(n >> 8) & 0xFF,
(n >> 0) & 0xFF,
n & 0xFF,
]);
};
@ -55,7 +55,7 @@ const fromUint32toUint8 = (n: number) => {
(n >> 24) & 0xFF,
(n >> 16) & 0xFF,
(n >> 8) & 0xFF,
(n >> 0) & 0xFF,
n & 0xFF,
]);
};
@ -64,7 +64,7 @@ const fromInt8ToUint8 = (n: number) => {
throw new Error(`Number ${n} is not within the int8 range`);
}
return (n >> 0) & 0xFF;
return n & 0xFF;
};
const keyboardLedStateMasks = {
@ -219,32 +219,32 @@ export interface KeyboardMacroStep extends KeysDownState {
export class KeyboardMacroReportMessage extends RpcMessage {
isPaste: boolean;
length: number;
stepCount: number;
steps: KeyboardMacroStep[];
KEYS_LENGTH = 6;
KEYS_LENGTH = hidKeyBufferSize;
constructor(isPaste: boolean, length: number, steps: KeyboardMacroStep[]) {
constructor(isPaste: boolean, stepCount: number, steps: KeyboardMacroStep[]) {
super(HID_RPC_MESSAGE_TYPES.KeyboardMacroReport);
this.isPaste = isPaste;
this.length = length;
this.stepCount = stepCount;
this.steps = steps;
}
marshal(): Uint8Array {
// validate if length is correct
if (this.length !== this.steps.length) {
throw new Error(`Length ${this.length} is not equal to the number of steps ${this.steps.length}`);
if (this.stepCount !== this.steps.length) {
throw new Error(`Length ${this.stepCount} is not equal to the number of steps ${this.steps.length}`);
}
const data = new Uint8Array(this.length * 9 + 6);
const data = new Uint8Array(this.stepCount * 9 + 6);
data.set(new Uint8Array([
this.messageType,
this.isPaste ? 1 : 0,
...fromUint32toUint8(this.length),
...fromUint32toUint8(this.stepCount),
]), 0);
for (let i = 0; i < this.length; i++) {
for (let i = 0; i < this.stepCount; i++) {
const step = this.steps[i];
if (!withinUint8Range(step.modifier)) {
throw new Error(`Modifier ${step.modifier} is not within the uint8 range`);
@ -279,12 +279,12 @@ export class KeyboardMacroReportMessage extends RpcMessage {
}
}
export class KeyboardMacroStateReportMessage extends RpcMessage {
export class KeyboardMacroStateMessage extends RpcMessage {
state: boolean;
isPaste: boolean;
constructor(state: boolean, isPaste: boolean) {
super(HID_RPC_MESSAGE_TYPES.KeyboardMacroStateReport);
super(HID_RPC_MESSAGE_TYPES.KeyboardMacroState);
this.state = state;
this.isPaste = isPaste;
}
@ -297,12 +297,12 @@ export class KeyboardMacroStateReportMessage extends RpcMessage {
]);
}
public static unmarshal(data: Uint8Array): KeyboardMacroStateReportMessage | undefined {
public static unmarshal(data: Uint8Array): KeyboardMacroStateMessage | undefined {
if (data.length < 1) {
throw new Error(`Invalid keyboard macro state report message length: ${data.length}`);
}
return new KeyboardMacroStateReportMessage(data[0] === 1, data[1] === 1);
return new KeyboardMacroStateMessage(data[0] === 1, data[1] === 1);
}
}
@ -417,7 +417,7 @@ export const messageRegistry = {
[HID_RPC_MESSAGE_TYPES.KeypressReport]: KeypressReportMessage,
[HID_RPC_MESSAGE_TYPES.KeyboardMacroReport]: KeyboardMacroReportMessage,
[HID_RPC_MESSAGE_TYPES.CancelKeyboardMacroReport]: CancelKeyboardMacroReportMessage,
[HID_RPC_MESSAGE_TYPES.KeyboardMacroStateReport]: KeyboardMacroStateReportMessage,
[HID_RPC_MESSAGE_TYPES.KeyboardMacroState]: KeyboardMacroStateMessage,
}
export const unmarshalHidRpcMessage = (data: Uint8Array): RpcMessage | undefined => {

View File

@ -105,6 +105,9 @@ export interface RTCState {
setRpcDataChannel: (channel: RTCDataChannel) => void;
rpcDataChannel: RTCDataChannel | null;
hidRpcDisabled: boolean;
setHidRpcDisabled: (disabled: boolean) => void;
rpcHidProtocolVersion: number | null;
setRpcHidProtocolVersion: (version: number) => void;
@ -157,6 +160,9 @@ export const useRTCStore = create<RTCState>(set => ({
rpcDataChannel: null,
setRpcDataChannel: (channel: RTCDataChannel) => set({ rpcDataChannel: channel }),
hidRpcDisabled: false,
setHidRpcDisabled: (disabled: boolean) => set({ hidRpcDisabled: disabled }),
rpcHidProtocolVersion: null,
setRpcHidProtocolVersion: (version: number) => set({ rpcHidProtocolVersion: version }),
@ -464,7 +470,7 @@ export interface HidState {
isVirtualKeyboardEnabled: boolean;
setVirtualKeyboardEnabled: (enabled: boolean) => void;
isPasteModeEnabled: boolean;
isPasteInProgress: boolean;
setPasteModeEnabled: (enabled: boolean) => void;
usbState: USBStates;
@ -481,8 +487,8 @@ export const useHidStore = create<HidState>(set => ({
isVirtualKeyboardEnabled: false,
setVirtualKeyboardEnabled: (enabled: boolean): void => set({ isVirtualKeyboardEnabled: enabled }),
isPasteModeEnabled: false,
setPasteModeEnabled: (enabled: boolean): void => set({ isPasteModeEnabled: enabled }),
isPasteInProgress: false,
setPasteModeEnabled: (enabled: boolean): void => set({ isPasteInProgress: enabled }),
// Add these new properties for USB state
usbState: "not attached",

View File

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

View File

@ -1,4 +1,4 @@
import { useCallback, useMemo } from "react";
import { useCallback, useRef } from "react";
import {
hidErrorRollOver,
@ -9,13 +9,40 @@ import {
} from "@/hooks/stores";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { useHidRpc } from "@/hooks/useHidRpc";
import { KeyboardLedStateMessage, KeyboardMacroStateReportMessage, KeyboardMacroStep, KeysDownStateMessage } from "@/hooks/hidRpc";
import {
KeyboardLedStateMessage,
KeyboardMacroStateMessage,
KeyboardMacroStep,
KeysDownStateMessage,
} from "@/hooks/hidRpc";
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() {
const { send } = useJsonRpc();
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
// being tracked on the browser/client-side. When adding the keyPressReport API to the
@ -43,59 +70,61 @@ export default function useKeyboard() {
case KeyboardLedStateMessage:
setKeyboardLedState((message as KeyboardLedStateMessage).keyboardLedState);
break;
case KeyboardMacroStateReportMessage:
if (!(message as KeyboardMacroStateReportMessage).isPaste) break;
setPasteModeEnabled((message as KeyboardMacroStateReportMessage).state);
break;
case KeyboardMacroStateMessage:
if (!(message as KeyboardMacroStateMessage).isPaste) break;
setPasteModeEnabled((message as KeyboardMacroStateMessage).state);
break;
default:
break;
}
});
// 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" && !rpcHidReady) return;
const handleLegacyKeyboardReport = useCallback(
async (keys: number[], modifier: number) => {
send("keyboardReport", { keys, modifier }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
console.error(`Failed to send keyboard report ${keys} ${modifier}`, resp.error);
}
console.debug(
`Send keyboardReport keys: ${state.keys}, modifier: ${state.modifier}`,
);
// On older backends, we need to set the keysDownState manually since without the hidRpc API, the state doesn't trickle down from the backend
setKeysDownState({ modifier, keys });
});
},
[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) {
console.debug("Sending keyboard report via HidRPC");
sendKeyboardEventHidRpc(state.keys, state.modifier);
return;
}
ac?.signal?.addEventListener("abort", abortListener);
send(
"keyboardReport",
{ keys: state.keys, modifier: state.modifier },
(resp: JsonRpcResponse) => {
if ("error" in resp) {
console.error(`Failed to send keyboard report ${state}`, resp.error);
}
{ keys, modifier },
params => {
if ("error" in params) return reject(params.error);
resolve();
},
);
},
[rpcDataChannel?.readyState, rpcHidReady, send, sendKeyboardEventHidRpc],
);
const MACRO_RESET_KEYBOARD_STATE = useMemo(() => ({
keys: new Array(hidKeyBufferSize).fill(0),
modifier: 0,
delay: 0,
}), []);
});
}, [send]);
// 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
sendKeyboardEvent(MACRO_RESET_KEYBOARD_STATE);
}, [sendKeyboardEvent, MACRO_RESET_KEYBOARD_STATE]);
const { keys, modifier } = 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.
// 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.
// 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 }[],
) => {
const executeMacroRemote = useCallback(async (steps: MacroSteps) => {
const macro: KeyboardMacroStep[] = [];
for (const [_, step] of steps.entries()) {
@ -122,12 +149,73 @@ export default function useKeyboard() {
}
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 () => {
if (abortController.current) {
abortController.current.abort();
}
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();
}, [rpcHidReady, cancelOngoingKeyboardMacroHidRpc]);
}, [rpcHidReady, cancelOngoingKeyboardMacroHidRpc, abortController]);
// handleKeyPress is used to handle a key press or release event.
// 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.
sendKeypressEventHidRpc(key, press);
} 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(
keysDownState,
key,
press,
);
sendKeyboardEvent(downState); // then we send the full state
handleLegacyKeyboardReport(downState.keys, downState.modifier);
// if we just sent ErrorRollOver, reset to empty state
if (downState.keys[0] === hidErrorRollOver) {
@ -164,12 +255,12 @@ export default function useKeyboard() {
}
},
[
rpcHidReady,
keysDownState,
resetKeyboardState,
rpcDataChannel?.readyState,
sendKeyboardEvent,
rpcHidReady,
sendKeypressEventHidRpc,
keysDownState,
handleLegacyKeyboardReport,
resetKeyboardState,
],
);

View File

@ -583,6 +583,7 @@ export default function KvmIdRoute() {
keyboardLedState, setKeyboardLedState,
keysDownState, setKeysDownState, setUsbState,
} = useHidStore();
const setHidRpcDisabled = useRTCStore(state => state.setHidRpcDisabled);
const [hasUpdated, setHasUpdated] = useState(false);
const { navigateTo } = useDeviceUiNavigation();
@ -695,6 +696,7 @@ export default function KvmIdRoute() {
if (resp.error.code === -32601) {
// 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);
setHidRpcDisabled(true);
} else {
console.error("Failed to get key down state", resp.error);
}
@ -705,7 +707,7 @@ export default function KvmIdRoute() {
}
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
useEffect(() => {

2
web.go
View File

@ -199,7 +199,7 @@ func handleWebRTCSession(c *gin.Context) {
}()
}
// Cancel any ongoing keyboard report multi when session changes
// Cancel any ongoing keyboard macro when session changes
cancelKeyboardMacro()
currentSession = session