wip: send macro using hidRPC channel

This commit is contained in:
Siyuan Miao 2025-09-10 02:42:36 +02:00
parent f58e5476bf
commit 024cbb8fb1
7 changed files with 310 additions and 174 deletions

View File

@ -1,6 +1,7 @@
package kvm package kvm
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@ -31,6 +32,13 @@ func handleHidRPCMessage(message hidrpc.Message, session *Session) {
session.reportHidRPCKeysDownState(*keysDownState) session.reportHidRPCKeysDownState(*keysDownState)
} }
rpcErr = err rpcErr = err
case hidrpc.TypeKeyboardMacroReport:
keyboardMacroReport, err := message.KeyboardMacroReport()
if err != nil {
logger.Warn().Err(err).Msg("failed to get keyboard macro report")
return
}
_, rpcErr = rpcKeyboardReportMulti(context.Background(), keyboardMacroReport.Macro)
case hidrpc.TypePointerReport: case hidrpc.TypePointerReport:
pointerReport, err := message.PointerReport() pointerReport, err := message.PointerReport()
if err != nil { if err != nil {

View File

@ -16,8 +16,11 @@ const (
TypeWheelReport MessageType = 0x04 TypeWheelReport MessageType = 0x04
TypeKeypressReport MessageType = 0x05 TypeKeypressReport MessageType = 0x05
TypeMouseReport MessageType = 0x06 TypeMouseReport MessageType = 0x06
TypeKeyboardMacroReport MessageType = 0x07
TypeCancelKeyboardMacroReport MessageType = 0x08
TypeKeyboardLedState MessageType = 0x32 TypeKeyboardLedState MessageType = 0x32
TypeKeydownState MessageType = 0x33 TypeKeydownState MessageType = 0x33
TypeKeyboardMacroStateReport MessageType = 0x34
) )
const ( const (
@ -29,7 +32,7 @@ func GetQueueIndex(messageType MessageType) int {
switch messageType { switch messageType {
case TypeHandshake: case TypeHandshake:
return 0 return 0
case TypeKeyboardReport, TypeKeypressReport, TypeKeyboardLedState, TypeKeydownState: case TypeKeyboardReport, TypeKeypressReport, TypeKeyboardLedState, TypeKeydownState, TypeKeyboardMacroStateReport:
return 1 return 1
case TypePointerReport, TypeMouseReport, TypeWheelReport: case TypePointerReport, TypeMouseReport, TypeWheelReport:
return 2 return 2

View File

@ -1,6 +1,7 @@
package hidrpc package hidrpc
import ( import (
"encoding/binary"
"fmt" "fmt"
) )
@ -43,6 +44,11 @@ func (m *Message) String() string {
return fmt.Sprintf("MouseReport{Malformed: %v}", m.d) return fmt.Sprintf("MouseReport{Malformed: %v}", m.d)
} }
return fmt.Sprintf("MouseReport{DX: %d, DY: %d, Button: %d}", m.d[0], m.d[1], m.d[2]) return fmt.Sprintf("MouseReport{DX: %d, DY: %d, Button: %d}", m.d[0], m.d[1], m.d[2])
case TypeKeyboardMacroReport:
if len(m.d) < 5 {
return fmt.Sprintf("KeyboardMacroReport{Malformed: %v}", m.d)
}
return fmt.Sprintf("KeyboardMacroReport{IsPaste: %v, Length: %d}", m.d[0] == uint8(1), binary.BigEndian.Uint32(m.d[1:5]))
default: default:
return fmt.Sprintf("Unknown{Type: %d, Data: %v}", m.t, m.d) return fmt.Sprintf("Unknown{Type: %d, Data: %v}", m.t, m.d)
} }
@ -84,6 +90,51 @@ func (m *Message) KeyboardReport() (KeyboardReport, error) {
}, nil }, nil
} }
// Macro ..
type KeyboardMacro struct {
Modifier byte // 1 byte
Keys []byte // 6 bytes, to make things easier, the keys length is fixed to 6
Delay uint16 // 2 bytes
}
type KeyboardMacroReport struct {
IsPaste bool
Length uint32
Macro []KeyboardMacro
}
// KeyboardMacroReport returns the keyboard macro report from the message.
func (m *Message) KeyboardMacroReport() (KeyboardMacroReport, error) {
if m.t != TypeKeyboardMacroReport {
return KeyboardMacroReport{}, fmt.Errorf("invalid message type: %d", m.t)
}
isPaste := m.d[0] == uint8(1)
length := binary.BigEndian.Uint32(m.d[1:5])
// check total length
expectedLength := int(length)*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{
Modifier: m.d[offset],
Keys: m.d[offset+1 : offset+7],
Delay: binary.BigEndian.Uint16(m.d[offset+7 : offset+9]),
})
}
return KeyboardMacroReport{
IsPaste: isPaste,
Macro: macro,
Length: length,
}, nil
}
// PointerReport .. // PointerReport ..
type PointerReport struct { type PointerReport struct {
X int X int

View File

@ -10,13 +10,13 @@ import (
"path/filepath" "path/filepath"
"reflect" "reflect"
"strconv" "strconv"
"sync"
"time" "time"
"github.com/pion/webrtc/v4" "github.com/pion/webrtc/v4"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"go.bug.st/serial" "go.bug.st/serial"
"github.com/jetkvm/kvm/internal/hidrpc"
"github.com/jetkvm/kvm/internal/usbgadget" "github.com/jetkvm/kvm/internal/usbgadget"
) )
@ -1050,52 +1050,56 @@ func rpcSetLocalLoopbackOnly(enabled bool) error {
return nil return nil
} }
// cancelKeyboardReportMulti cancels any ongoing keyboard report multi execution
func cancelKeyboardReportMulti() { func cancelKeyboardReportMulti() {
keyboardReportMultiLock.Lock()
defer keyboardReportMultiLock.Unlock()
if keyboardReportMultiCancel != nil {
keyboardReportMultiCancel()
logger.Info().Msg("canceled keyboard report multi")
keyboardReportMultiCancel = nil
}
} }
func setKeyboardReportMultiCancel(cancel context.CancelFunc) { // // cancelKeyboardReportMulti cancels any ongoing keyboard report multi execution
keyboardReportMultiLock.Lock() // func cancelKeyboardReportMulti() {
defer keyboardReportMultiLock.Unlock() // keyboardReportMultiLock.Lock()
// defer keyboardReportMultiLock.Unlock()
keyboardReportMultiCancel = cancel // if keyboardReportMultiCancel != nil {
} // keyboardReportMultiCancel()
// logger.Info().Msg("canceled keyboard report multi")
// keyboardReportMultiCancel = nil
// }
// }
func rpcKeyboardReportMultiWrapper(macro []map[string]any) (usbgadget.KeysDownState, error) { // func setKeyboardReportMultiCancel(cancel context.CancelFunc) {
cancelKeyboardReportMulti() // keyboardReportMultiLock.Lock()
// defer keyboardReportMultiLock.Unlock()
ctx, cancel := context.WithCancel(context.Background()) // keyboardReportMultiCancel = cancel
setKeyboardReportMultiCancel(cancel) // }
writeJSONRPCEvent("keyboardReportMultiState", true, currentSession) // func rpcKeyboardReportMultiWrapper(macro []map[string]any) (usbgadget.KeysDownState, error) {
// // cancelKeyboardReportMulti()
result, err := rpcKeyboardReportMulti(ctx, macro) // // ctx, cancel := context.WithCancel(context.Background())
// // setKeyboardReportMultiCancel(cancel)
setKeyboardReportMultiCancel(nil) // // writeJSONRPCEvent("keyboardReportMultiState", true, currentSession)
writeJSONRPCEvent("keyboardReportMultiState", false, currentSession) // // result, err := rpcKeyboardReportMulti(ctx, macro)
return result, err // // setKeyboardReportMultiCancel(nil)
}
var ( // // writeJSONRPCEvent("keyboardReportMultiState", false, currentSession)
keyboardReportMultiCancel context.CancelFunc
keyboardReportMultiLock sync.Mutex
)
func rpcCancelKeyboardReportMulti() { // // return result, err
cancelKeyboardReportMulti() // }
}
func rpcKeyboardReportMulti(ctx context.Context, macro []map[string]any) (usbgadget.KeysDownState, error) { // var (
// keyboardReportMultiCancel context.CancelFunc
// keyboardReportMultiLock sync.Mutex
// )
// func rpcCancelKeyboardReportMulti() {
// cancelKeyboardReportMulti()
// }
func rpcKeyboardReportMulti(ctx context.Context, macro []hidrpc.KeyboardMacro) (usbgadget.KeysDownState, error) {
var last usbgadget.KeysDownState var last usbgadget.KeysDownState
var err error var err error
@ -1110,45 +1114,23 @@ func rpcKeyboardReportMulti(ctx context.Context, macro []map[string]any) (usbgad
default: default:
} }
var modifier byte delay := time.Duration(step.Delay) * time.Millisecond
if m, ok := step["modifier"].(float64); ok { logger.Info().Int("step", i).Uint16("delay", step.Delay).Msg("Keyboard report multi delay")
modifier = byte(int(m))
} else if mi, ok := step["modifier"].(int); ok {
modifier = byte(mi)
} else if mb, ok := step["modifier"].(uint8); ok {
modifier = mb
}
var keys []byte last, err = rpcKeyboardReport(step.Modifier, step.Keys)
if arr, ok := step["keys"].([]any); ok { if err != nil {
keys = make([]byte, 0, len(arr)) logger.Warn().Err(err).Msg("failed to execute keyboard report multi")
for _, v := range arr { return last, err
if f, ok := v.(float64); ok {
keys = append(keys, byte(int(f)))
} else if i, ok := v.(int); ok {
keys = append(keys, byte(i))
} else if b, ok := v.(uint8); ok {
keys = append(keys, b)
}
}
} else if bs, ok := step["keys"].([]byte); ok {
keys = bs
} }
// Use context-aware sleep that can be cancelled // Use context-aware sleep that can be cancelled
select { select {
case <-time.After(100 * time.Millisecond): case <-time.After(delay):
// Sleep completed normally // Sleep completed normally
case <-ctx.Done(): case <-ctx.Done():
logger.Debug().Int("step", i).Msg("Keyboard report multi cancelled during sleep") logger.Debug().Int("step", i).Msg("Keyboard report multi cancelled during sleep")
return last, ctx.Err() return last, ctx.Err()
} }
last, err = rpcKeyboardReport(modifier, keys)
if err != nil {
logger.Warn().Err(err).Msg("failed to execute keyboard report multi")
return last, err
}
} }
return last, nil return last, nil
@ -1165,8 +1147,8 @@ var rpcHandlers = map[string]RPCHandler{
"setNetworkSettings": {Func: rpcSetNetworkSettings, Params: []string{"settings"}}, "setNetworkSettings": {Func: rpcSetNetworkSettings, Params: []string{"settings"}},
"renewDHCPLease": {Func: rpcRenewDHCPLease}, "renewDHCPLease": {Func: rpcRenewDHCPLease},
"keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}}, "keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}},
"keyboardReportMulti": {Func: rpcKeyboardReportMultiWrapper, Params: []string{"macro"}}, // "keyboardReportMulti": {Func: rpcKeyboardReportMultiWrapper, Params: []string{"macro"}},
"cancelKeyboardReportMulti": {Func: rpcCancelKeyboardReportMulti}, // "cancelKeyboardReportMulti": {Func: rpcCancelKeyboardReportMulti},
"getKeyboardLedState": {Func: rpcGetKeyboardLedState}, "getKeyboardLedState": {Func: rpcGetKeyboardLedState},
"keypressReport": {Func: rpcKeypressReport, Params: []string{"key", "press"}}, "keypressReport": {Func: rpcKeypressReport, Params: []string{"key", "press"}},
"getKeyDownState": {Func: rpcGetKeysDownState}, "getKeyDownState": {Func: rpcGetKeysDownState},

View File

@ -7,6 +7,7 @@ export const HID_RPC_MESSAGE_TYPES = {
WheelReport: 0x04, WheelReport: 0x04,
KeypressReport: 0x05, KeypressReport: 0x05,
MouseReport: 0x06, MouseReport: 0x06,
KeyboardMacroReport: 0x07,
KeyboardLedState: 0x32, KeyboardLedState: 0x32,
KeysDownState: 0x33, KeysDownState: 0x33,
} }
@ -32,6 +33,30 @@ const fromInt32toUint8 = (n: number) => {
]); ]);
}; };
const fromUint16toUint8 = (n: number) => {
if (n > 65535 || n < 0) {
throw new Error(`Number ${n} is not within the uint16 range`);
}
return new Uint8Array([
(n >> 8) & 0xFF,
(n >> 0) & 0xFF,
]);
};
const fromUint32toUint8 = (n: number) => {
if (n > 4294967295 || n < 0) {
throw new Error(`Number ${n} is not within the uint32 range`);
}
return new Uint8Array([
(n >> 24) & 0xFF,
(n >> 16) & 0xFF,
(n >> 8) & 0xFF,
(n >> 0) & 0xFF,
]);
};
const fromInt8ToUint8 = (n: number) => { const fromInt8ToUint8 = (n: number) => {
if (n < -128 || n > 127) { if (n < -128 || n > 127) {
throw new Error(`Number ${n} is not within the int8 range`); throw new Error(`Number ${n} is not within the int8 range`);
@ -186,6 +211,64 @@ export class KeyboardReportMessage extends RpcMessage {
} }
} }
export interface KeyboardMacro extends KeysDownState {
delay: number;
}
export class KeyboardMacroReportMessage extends RpcMessage {
isPaste: boolean;
length: number;
macro: KeyboardMacro[];
KEYS_LENGTH = 6;
constructor(isPaste: boolean, length: number, macro: KeyboardMacro[]) {
super(HID_RPC_MESSAGE_TYPES.KeyboardMacroReport);
this.isPaste = isPaste;
this.length = length;
this.macro = macro;
}
marshal(): Uint8Array {
const dataHeader = new Uint8Array([
this.messageType,
this.isPaste ? 1 : 0,
...fromUint32toUint8(this.length),
]);
let dataBody = new Uint8Array();
for (const step of this.macro) {
if (!withinUint8Range(step.modifier)) {
throw new Error(`Modifier ${step.modifier} is not within the uint8 range`);
}
// Ensure the keys are within the KEYS_LENGTH range
const keys = step.keys;
if (keys.length > this.KEYS_LENGTH) {
throw new Error(`Keys ${keys} is not within the hidKeyBufferSize range`);
} else if (keys.length < this.KEYS_LENGTH) {
keys.push(...Array(this.KEYS_LENGTH - keys.length).fill(0));
}
for (const key of keys) {
if (!withinUint8Range(key)) {
throw new Error(`Key ${key} is not within the uint8 range`);
}
}
const macroBinary = new Uint8Array([
step.modifier,
...keys,
...fromUint16toUint8(step.delay),
]);
dataBody = new Uint8Array([...dataBody, ...macroBinary]);
}
return new Uint8Array([...dataHeader, ...dataBody]);
}
}
export class KeyboardLedStateMessage extends RpcMessage { export class KeyboardLedStateMessage extends RpcMessage {
keyboardLedState: KeyboardLedState; keyboardLedState: KeyboardLedState;

View File

@ -5,6 +5,8 @@ import { useRTCStore } from "@/hooks/stores";
import { import {
HID_RPC_VERSION, HID_RPC_VERSION,
HandshakeMessage, HandshakeMessage,
KeyboardMacro,
KeyboardMacroReportMessage,
KeyboardReportMessage, KeyboardReportMessage,
KeypressReportMessage, KeypressReportMessage,
MouseReportMessage, MouseReportMessage,
@ -68,6 +70,15 @@ export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) {
[sendMessage], [sendMessage],
); );
const reportKeyboardMacroEvent = useCallback(
(macro: KeyboardMacro[]) => {
const d = new KeyboardMacroReportMessage(false, macro.length, macro);
sendMessage(d);
console.log("Sent keyboard macro report", d, d.marshal());
},
[sendMessage],
);
const sendHandshake = useCallback(() => { const sendHandshake = useCallback(() => {
if (rpcHidProtocolVersion) return; if (rpcHidProtocolVersion) return;
if (!rpcHidChannel) return; if (!rpcHidChannel) return;
@ -143,6 +154,7 @@ export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) {
reportKeypressEvent, reportKeypressEvent,
reportAbsMouseEvent, reportAbsMouseEvent,
reportRelMouseEvent, reportRelMouseEvent,
reportKeyboardMacroEvent,
rpcHidProtocolVersion, rpcHidProtocolVersion,
rpcHidReady, rpcHidReady,
rpcHidStatus, rpcHidStatus,

View File

@ -1,4 +1,4 @@
import { useCallback } from "react"; import { useCallback, useMemo } from "react";
import { import {
hidErrorRollOver, hidErrorRollOver,
@ -9,7 +9,7 @@ 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, KeysDownStateMessage } from "@/hooks/hidRpc"; import { KeyboardLedStateMessage, KeyboardMacro, KeysDownStateMessage } from "@/hooks/hidRpc";
import { hidKeyToModifierMask, keys, modifiers } from "@/keyboardMappings"; import { hidKeyToModifierMask, keys, modifiers } from "@/keyboardMappings";
export default function useKeyboard() { export default function useKeyboard() {
@ -32,6 +32,7 @@ export default function useKeyboard() {
const { const {
reportKeyboardEvent: sendKeyboardEventHidRpc, reportKeyboardEvent: sendKeyboardEventHidRpc,
reportKeypressEvent: sendKeypressEventHidRpc, reportKeypressEvent: sendKeypressEventHidRpc,
reportKeyboardMacroEvent: sendKeyboardMacroEventHidRpc,
rpcHidReady, rpcHidReady,
} = useHidRpc(message => { } = useHidRpc(message => {
switch (message.constructor) { switch (message.constructor) {
@ -77,16 +78,19 @@ export default function useKeyboard() {
[rpcDataChannel?.readyState, rpcHidReady, send, sendKeyboardEventHidRpc], [rpcDataChannel?.readyState, rpcHidReady, send, sendKeyboardEventHidRpc],
); );
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
keysDownState.keys.length = hidKeyBufferSize; sendKeyboardEvent(MACRO_RESET_KEYBOARD_STATE);
keysDownState.keys.fill(0); }, [sendKeyboardEvent, MACRO_RESET_KEYBOARD_STATE]);
keysDownState.modifier = 0;
sendKeyboardEvent(keysDownState);
}, [keysDownState, sendKeyboardEvent]);
// 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.
@ -97,7 +101,7 @@ export default function useKeyboard() {
const executeMacro = async ( const executeMacro = async (
steps: { keys: string[] | null; modifiers: string[] | null; delay: number }[], steps: { keys: string[] | null; modifiers: string[] | null; delay: number }[],
) => { ) => {
const macro: KeysDownState[] = []; const macro: KeyboardMacro[] = [];
for (const [_, step] of steps.entries()) { for (const [_, step] of steps.entries()) {
const keyValues = (step.keys || []).map(key => keys[key]).filter(Boolean); const keyValues = (step.keys || []).map(key => keys[key]).filter(Boolean);
@ -107,19 +111,12 @@ export default function useKeyboard() {
// If the step has keys and/or modifiers, press them and hold for the delay // If the step has keys and/or modifiers, press them and hold for the delay
if (keyValues.length > 0 || modifierMask > 0) { if (keyValues.length > 0 || modifierMask > 0) {
macro.push({ keys: keyValues, modifier: modifierMask }); macro.push({ keys: keyValues, modifier: modifierMask, delay: 50 });
keysDownState.keys.length = hidKeyBufferSize; macro.push({ ...MACRO_RESET_KEYBOARD_STATE, delay: 200 });
keysDownState.keys.fill(0);
keysDownState.modifier = 0;
macro.push(keysDownState);
} }
} }
// KeyboardReportMessage
send("keyboardReportMulti", { macro }, (resp: JsonRpcResponse) => { sendKeyboardMacroEventHidRpc(macro);
if ("error" in resp) {
console.error(`Failed to send keyboard report ${macro}`, resp.error);
}
});
}; };
const cancelExecuteMacro = useCallback(async () => { const cancelExecuteMacro = useCallback(async () => {