feat(input): add absolute pointer and scroll

This commit is contained in:
sechmachine
2026-08-11 01:09:42 +07:00
parent bfffea5d2d
commit f981823909
10 changed files with 285 additions and 34 deletions
+43 -3
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/binary"
"encoding/hex"
"errors"
"net"
"testing"
"time"
@@ -328,13 +329,52 @@ func TestApolloControlWireVectorAndTagFailure(t *testing.T) {
}
func TestApolloKeyboardInputWireVector(t *testing.T) {
packet, err := encodeApolloInputEvent(InputEvent{Device: "keyboard", Code: 30, Pressed: true, Payload: []byte{2}})
packets, err := encodeApolloInputEvent(InputEvent{Device: "keyboard", Code: 30, Pressed: true, Payload: []byte{2}})
if err != nil {
t.Fatal(err)
}
const expected = "0000000a03000000001e00020000"
if string(packet.payload) != string(mustDecodeHex(t, expected)) {
t.Fatalf("keyboard packet = %x, want %s", packet.payload, expected)
if len(packets) != 1 || string(packets[0].payload) != string(mustDecodeHex(t, expected)) {
t.Fatalf("keyboard packets = %#v, want %s", packets, expected)
}
}
func TestApolloAbsoluteAndScrollInputWireVectors(t *testing.T) {
// Independently implemented from the approved Apollo adc5c5a0 input.cpp
// consumer and its moonlight-common-c c999436 Input.h/InputStream.c pin.
absolute, err := encodeApolloInputEvent(InputEvent{Device: "mouse-absolute", Payload: []byte{0x04, 0xd2, 0x02, 0x37, 0x0a, 0x00, 0x05, 0xa0}})
if err != nil {
t.Fatal(err)
}
const absoluteExpected = "0000000e0500000004d20237000009ff059f"
if len(absolute) != 1 || absolute[0].channel != apolloChannelMouse || string(absolute[0].payload) != string(mustDecodeHex(t, absoluteExpected)) {
t.Fatalf("absolute packets = %#v, want %s", absolute, absoluteExpected)
}
scroll, err := encodeApolloInputEvent(InputEvent{Device: "mouse-scroll", Payload: []byte{0xff, 0x88, 0x00, 0x78}})
if err != nil {
t.Fatal(err)
}
const verticalExpected = "0000000a0a000000ff88ff880000"
const horizontalExpected = "00000006010000550078"
if len(scroll) != 2 || scroll[0].channel != apolloChannelMouse || scroll[1].channel != apolloChannelMouse ||
string(scroll[0].payload) != string(mustDecodeHex(t, verticalExpected)) || string(scroll[1].payload) != string(mustDecodeHex(t, horizontalExpected)) {
t.Fatalf("scroll packets = %#v", scroll)
}
zero, err := encodeApolloInputEvent(InputEvent{Device: "mouse-scroll", Payload: make([]byte, 4)})
if err != nil || len(zero) != 0 {
t.Fatalf("zero scroll packets = %#v, %v", zero, err)
}
for _, payload := range [][]byte{
{0, 0, 0, 0, 0, 1, 0, 2},
{0, 0, 0, 0, 0x80, 0, 0, 2},
{0, 0, 0, 0, 0, 2, 0x80, 0},
} {
if _, err := encodeApolloInputEvent(InputEvent{Device: "mouse-absolute", Payload: payload}); !errors.Is(err, ErrInputMalformed) {
t.Fatalf("Apollo accepted unrepresentable absolute payload %x: %v", payload, err)
}
}
}
+48 -12
View File
@@ -19,11 +19,11 @@ type apolloInputPacket struct {
payload []byte
}
func encodeApolloInputEvent(event InputEvent) (apolloInputPacket, error) {
func encodeApolloInputEvent(event InputEvent) ([]apolloInputPacket, error) {
switch event.Device {
case "keyboard":
if event.Code < 0 || event.Code > 0xffff || len(event.Payload) > 1 {
return apolloInputPacket{}, ErrInputMalformed
return nil, ErrInputMalformed
}
packet := make([]byte, 14)
binary.BigEndian.PutUint32(packet[:4], 10)
@@ -36,10 +36,10 @@ func encodeApolloInputEvent(event InputEvent) (apolloInputPacket, error) {
if len(event.Payload) == 1 {
packet[11] = event.Payload[0]
}
return apolloInputPacket{channel: apolloChannelKeyboard, payload: packet}, nil
return []apolloInputPacket{{channel: apolloChannelKeyboard, payload: packet}}, nil
case "mouse-button":
if event.Code < 1 || event.Code > 8 || len(event.Payload) != 0 {
return apolloInputPacket{}, ErrInputMalformed
return nil, ErrInputMalformed
}
packet := make([]byte, 9)
binary.BigEndian.PutUint32(packet[:4], 5)
@@ -49,28 +49,28 @@ func encodeApolloInputEvent(event InputEvent) (apolloInputPacket, error) {
}
binary.LittleEndian.PutUint32(packet[4:8], magic)
packet[8] = byte(event.Code)
return apolloInputPacket{channel: apolloChannelMouse, payload: packet}, nil
return []apolloInputPacket{{channel: apolloChannelMouse, payload: packet}}, nil
case "mouse-relative":
if event.Pressed || len(event.Payload) != 4 {
return apolloInputPacket{}, ErrInputMalformed
return nil, ErrInputMalformed
}
packet := make([]byte, 12)
binary.BigEndian.PutUint32(packet[:4], 8)
binary.LittleEndian.PutUint32(packet[4:8], 7)
copy(packet[8:], event.Payload)
return apolloInputPacket{channel: apolloChannelMouse, payload: packet}, nil
return []apolloInputPacket{{channel: apolloChannelMouse, payload: packet}}, nil
case "utf8":
if event.Pressed || len(event.Payload) == 0 || len(event.Payload) > utf8.UTFMax || !utf8.Valid(event.Payload) || utf8.RuneCount(event.Payload) != 1 {
return apolloInputPacket{}, ErrInputMalformed
return nil, ErrInputMalformed
}
packet := make([]byte, 8+len(event.Payload))
binary.BigEndian.PutUint32(packet[:4], uint32(4+len(event.Payload)))
binary.LittleEndian.PutUint32(packet[4:8], 0x17)
copy(packet[8:], event.Payload)
return apolloInputPacket{channel: apolloChannelUTF8, payload: packet}, nil
return []apolloInputPacket{{channel: apolloChannelUTF8, payload: packet}}, nil
case "controller":
if event.Code < 0 || event.Code > 15 || len(event.Payload) != 16 {
return apolloInputPacket{}, ErrInputMalformed
return nil, ErrInputMalformed
}
packet := make([]byte, 34)
binary.BigEndian.PutUint32(packet[:4], 30)
@@ -83,8 +83,44 @@ func encodeApolloInputEvent(event InputEvent) (apolloInputPacket, error) {
binary.LittleEndian.PutUint16(packet[28:30], 0x9c)
copy(packet[30:32], event.Payload[14:16])
binary.LittleEndian.PutUint16(packet[32:34], 0x55)
return apolloInputPacket{channel: apolloChannelGamepad + uint8(event.Code), payload: packet}, nil
return []apolloInputPacket{{channel: apolloChannelGamepad + uint8(event.Code), payload: packet}}, nil
case "mouse-absolute":
if event.Pressed || event.Code != 0 || !validAbsolutePayload(event.Payload) {
return nil, ErrInputMalformed
}
width, height := binary.BigEndian.Uint16(event.Payload[4:6]), binary.BigEndian.Uint16(event.Payload[6:8])
if width < 2 || height < 2 || width > 0x7fff || height > 0x7fff {
return nil, ErrInputMalformed
}
packet := make([]byte, 18)
binary.BigEndian.PutUint32(packet[:4], 14)
binary.LittleEndian.PutUint32(packet[4:8], 5)
copy(packet[8:12], event.Payload[:4])
binary.BigEndian.PutUint16(packet[14:16], width-1)
binary.BigEndian.PutUint16(packet[16:18], height-1)
return []apolloInputPacket{{channel: apolloChannelMouse, payload: packet}}, nil
case "mouse-scroll":
if event.Pressed || event.Code != 0 || len(event.Payload) != 4 {
return nil, ErrInputMalformed
}
packets := make([]apolloInputPacket, 0, 2)
if event.Payload[0] != 0 || event.Payload[1] != 0 {
packet := make([]byte, 14)
binary.BigEndian.PutUint32(packet[:4], 10)
binary.LittleEndian.PutUint32(packet[4:8], 10)
copy(packet[8:10], event.Payload[:2])
copy(packet[10:12], event.Payload[:2])
packets = append(packets, apolloInputPacket{channel: apolloChannelMouse, payload: packet})
}
if event.Payload[2] != 0 || event.Payload[3] != 0 {
packet := make([]byte, 10)
binary.BigEndian.PutUint32(packet[:4], 6)
binary.LittleEndian.PutUint32(packet[4:8], 0x55000001)
copy(packet[8:10], event.Payload[2:4])
packets = append(packets, apolloInputPacket{channel: apolloChannelMouse, payload: packet})
}
return packets, nil
default:
return apolloInputPacket{}, ErrInputMalformed
return nil, ErrInputMalformed
}
}
+5 -3
View File
@@ -454,15 +454,17 @@ func (s *nativeApolloSession) Audio() <-chan ProviderMedia { return s.audio }
func (s *nativeApolloSession) Events() <-chan ProviderEvent { return s.events }
func (s *nativeApolloSession) Input(ctx context.Context, event InputEvent) error {
packet, err := encodeApolloInputEvent(event)
packets, err := encodeApolloInputEvent(event)
if err != nil {
return err
}
if err := ctx.Err(); err != nil {
return err
}
if err := s.writeApolloControl(packet.channel, true, apolloControlTypeInput, packet.payload); err != nil {
return err
for _, packet := range packets {
if err := s.writeApolloControl(packet.channel, true, apolloControlTypeInput, packet.payload); err != nil {
return err
}
}
if event.Device == "keyboard" || event.Device == "mouse-button" || event.Device == "controller" {
key := fmt.Sprintf("%s:%d", event.Device, event.Code)
+4
View File
@@ -8,6 +8,10 @@ import (
var ErrNoCapabilityOverlap = errors.New("no capability overlap")
func DefaultFeatures() []string {
return []string{"quic-tls13", "datagram.media", "apollo", "display.request.v1", "input.absolute.v1", "input.scroll.v1"}
}
func DefaultCapabilities() protocol.CapabilityProfile {
return protocol.CapabilityProfile{
Transport: "quic-tls13",
+117 -2
View File
@@ -67,6 +67,10 @@ func FuzzDecodeFrame(f *testing.F) {
func FuzzDecodeInputEvent(f *testing.F) {
seed, _ := EncodeInputEvent(InputEvent{Sequence: 1, Device: "keyboard", Code: 7, Pressed: true})
f.Add(seed)
absolute, _ := EncodeInputEvent(InputEvent{Device: "mouse-absolute", Payload: []byte{0, 1, 0, 1, 0, 2, 0, 2}})
f.Add(absolute)
scroll, _ := EncodeInputEvent(InputEvent{Device: "mouse-scroll", Payload: []byte{0xff, 0x88, 0, 0x78}})
f.Add(scroll)
f.Add([]byte("VGI1"))
f.Fuzz(func(t *testing.T, data []byte) {
_, _ = DecodeInputEvent(data)
@@ -88,6 +92,107 @@ func TestInputEventUsesFixedProtocolVGI1Vector(t *testing.T) {
}
}
func TestInputEventUsesFixedAbsoluteAndScrollVectors(t *testing.T) {
tests := []struct {
name string
event InputEvent
expected string
}{
{
name: "absolute",
event: InputEvent{Device: "mouse-absolute", Payload: []byte{0x04, 0xd2, 0x02, 0x37, 0x0a, 0x00, 0x05, 0xa0}},
expected: "56474931060804d202370a0005a0",
},
{
name: "scroll",
event: InputEvent{Device: "mouse-scroll", Payload: []byte{0xff, 0x88, 0x00, 0x78}},
expected: "564749310704ff880078",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
encoded, err := EncodeInputEvent(test.event)
if err != nil {
t.Fatal(err)
}
if hex.EncodeToString(encoded) != test.expected {
t.Fatalf("EncodeInputEvent() = %x, want %s", encoded, test.expected)
}
decoded, err := DecodeInputEvent(encoded)
if err != nil || decoded.Device != test.event.Device || decoded.Code != 0 || decoded.Pressed || !bytes.Equal(decoded.Payload, test.event.Payload) {
t.Fatalf("DecodeInputEvent() = %#v, %v", decoded, err)
}
})
}
}
func TestInputEventRejectsMalformedAbsoluteAndScroll(t *testing.T) {
for _, event := range []InputEvent{
{Device: "mouse-absolute", Pressed: true, Payload: make([]byte, 8)},
{Device: "mouse-absolute", Payload: []byte{0, 0, 0, 0, 0, 0, 0, 1}},
{Device: "mouse-absolute", Payload: []byte{0, 2, 0, 0, 0, 2, 0, 1}},
{Device: "mouse-absolute", Payload: []byte{0, 0, 0, 1, 0, 2, 0, 1}},
{Device: "mouse-scroll", Pressed: true, Payload: make([]byte, 4)},
{Device: "mouse-scroll", Payload: make([]byte, 3)},
} {
if _, err := EncodeInputEvent(event); !errors.Is(err, ErrInputMalformed) {
t.Fatalf("EncodeInputEvent(%#v) = %v", event, err)
}
}
for _, value := range []string{
"56474931060800000000000005a0",
"5647493106080a0000000a0005a0",
"564749310608000005a00a0005a0",
"56474931060700000000010001",
"5647493107020000",
} {
raw, err := hex.DecodeString(value)
if err != nil {
t.Fatal(err)
}
if _, err := DecodeInputEvent(raw); !errors.Is(err, ErrInputMalformed) {
t.Fatalf("DecodeInputEvent(%s) = %v", value, err)
}
}
}
func TestGatewayRejectsUnadvertisedInputBeforeProviderTranslation(t *testing.T) {
provider := &fakeSession{
state: protocol.ProviderState{State: ProviderStateReady},
pressed: make(map[string]struct{}),
}
session := &gatewaySession{
server: &Server{config: ServerConfig{}},
provider: provider,
ctx: context.Background(),
pressed: make(map[string]struct{}),
}
absolute, err := EncodeInputEvent(InputEvent{Device: "mouse-absolute", Payload: []byte{0, 1, 0, 1, 0, 2, 0, 2}})
if err != nil {
t.Fatal(err)
}
if err := session.handleInput(absolute, 1); !errors.Is(err, ErrInputMalformed) {
t.Fatalf("unadvertised absolute input = %v", err)
}
if len(provider.inputs) != 0 {
t.Fatalf("unadvertised absolute input reached provider: %#v", provider.inputs)
}
session.server.config.Features = []string{"input.absolute.v1"}
if err := session.handleInput(absolute, 2); err != nil {
t.Fatal(err)
}
scroll, err := EncodeInputEvent(InputEvent{Device: "mouse-scroll", Payload: []byte{0, 1, 0, 1}})
if err != nil {
t.Fatal(err)
}
if err := session.handleInput(scroll, 3); !errors.Is(err, ErrInputMalformed) {
t.Fatalf("unadvertised scroll input = %v", err)
}
if len(provider.inputs) != 1 || len(session.pressed) != 0 {
t.Fatalf("provider inputs=%#v pressed=%#v", provider.inputs, session.pressed)
}
}
func TestClientFeedbackUsesFixedProtocolVGFVector(t *testing.T) {
feedback := Feedback{Sequence: 9, Kind: FeedbackFEC, Payload: []byte{0, 0, 0, 42, 0, 5, 0, 3, 0, 2, 0, 10, 0, 2, 0, 8, 0, 2, 20, 0, 1}}
encoded, err := EncodeClientFeedback(feedback)
@@ -581,6 +686,14 @@ func TestRegisteredChannelFramesTraversePublicTransport(t *testing.T) {
if err != nil {
t.Fatal(err)
}
absolute, err := EncodeInputEvent(InputEvent{Sequence: 10, Device: "mouse-absolute", Payload: []byte{0, 1, 0, 1, 0, 2, 0, 2}})
if err != nil {
t.Fatal(err)
}
scroll, err := EncodeInputEvent(InputEvent{Sequence: 11, Device: "mouse-scroll", Payload: []byte{0, 1, 0, 1}})
if err != nil {
t.Fatal(err)
}
feedback, err := EncodeClientFeedback(Feedback{Sequence: 8, Kind: FeedbackIDR})
if err != nil {
t.Fatal(err)
@@ -593,6 +706,8 @@ func TestRegisteredChannelFramesTraversePublicTransport(t *testing.T) {
testChannelFrame("input.sequenced.v1", 7, input),
testChannelFrame("control.ack.v1", 8, feedback),
testChannelFrame("clipboard.text.v1", 9, clipboard),
testChannelFrame("input.sequenced.v1", 10, absolute),
testChannelFrame("input.sequenced.v1", 11, scroll),
} {
encoded, encodeErr := protocol.EncodeChannelFrame(frame)
if encodeErr != nil {
@@ -617,7 +732,7 @@ func TestRegisteredChannelFramesTraversePublicTransport(t *testing.T) {
inputs := append([]InputEvent(nil), h.session.inputs...)
feedbacks := append([]Feedback(nil), h.session.feedback...)
h.session.mu.Unlock()
if len(inputs) == 1 && inputs[0].Sequence == 7 && len(feedbacks) == 1 && feedbacks[0].Sequence == 8 && feedbacks[0].Kind == FeedbackIDR {
if len(inputs) == 3 && inputs[0].Sequence == 7 && inputs[1].Sequence == 10 && inputs[1].Device == "mouse-absolute" && inputs[2].Sequence == 11 && inputs[2].Device == "mouse-scroll" && len(feedbacks) == 1 && feedbacks[0].Sequence == 8 && feedbacks[0].Kind == FeedbackIDR {
break
}
if time.Now().After(deadline) {
@@ -1320,7 +1435,7 @@ func newGatewayTransportHarnessWithClipboard(t *testing.T, clipboardEnabled bool
authority := protocol.SessionAuthority{Version: "1", SessionID: "session-transport", GatewayID: "gateway-1", Audience: "versevdi-gateway", ReconnectSequence: 0, ExpiresAt: time.Now().Add(5 * time.Second).UTC().Format(time.RFC3339Nano), Capabilities: DefaultCapabilities(), ProviderProfile: ProviderProfileApollo, ProviderIdentity: fake.config.Identity.Key()}
admission := &oneTimeAdmission{authority: authority, released: make(chan struct{}), disableClipboard: !clipboardEnabled}
reporter := &recordingProviderStateReporter{}
server, err := NewServer(ServerConfig{ListenAddress: "127.0.0.1:0", TLSConfig: serverTLS, GatewayID: "gateway-1", Capabilities: DefaultCapabilities(), ProviderCapabilities: DefaultCapabilities(), Admission: admission, ProviderStateReporter: reporter, ClipboardAuditReporter: reporter, Provider: fake})
server, err := NewServer(ServerConfig{ListenAddress: "127.0.0.1:0", TLSConfig: serverTLS, GatewayID: "gateway-1", Features: DefaultFeatures(), Capabilities: DefaultCapabilities(), ProviderCapabilities: DefaultCapabilities(), Admission: admission, ProviderStateReporter: reporter, ClipboardAuditReporter: reporter, Provider: fake})
if err != nil {
t.Fatal(err)
}
+39
View File
@@ -15,6 +15,8 @@ const (
gatewayInputRelative = 3
gatewayInputUTF8 = 4
gatewayInputController = 5
gatewayInputAbsolute = 6
gatewayInputScroll = 7
)
func EncodeInputEvent(event InputEvent) ([]byte, error) {
@@ -76,6 +78,24 @@ func EncodeInputEvent(event InputEvent) ([]byte, error) {
encoded[4], encoded[5], encoded[6] = gatewayInputController, 17, byte(event.Code)
copy(encoded[7:], event.Payload)
return encoded, nil
case "mouse-absolute":
if event.Pressed || event.Code != 0 || !validAbsolutePayload(event.Payload) {
return nil, ErrInputMalformed
}
encoded := make([]byte, gatewayInputHeaderSize+8)
copy(encoded, "VGI1")
encoded[4], encoded[5] = gatewayInputAbsolute, 8
copy(encoded[6:], event.Payload)
return encoded, nil
case "mouse-scroll":
if event.Pressed || event.Code != 0 || len(event.Payload) != 4 {
return nil, ErrInputMalformed
}
encoded := make([]byte, gatewayInputHeaderSize+4)
copy(encoded, "VGI1")
encoded[4], encoded[5] = gatewayInputScroll, 4
copy(encoded[6:], event.Payload)
return encoded, nil
default:
return nil, ErrInputMalformed
}
@@ -117,11 +137,30 @@ func DecodeInputEvent(data []byte) (InputEvent, error) {
return InputEvent{}, ErrInputMalformed
}
return InputEvent{Device: "controller", Code: int32(body[0]), Pressed: active != 0, Payload: payload}, nil
case gatewayInputAbsolute:
if !validAbsolutePayload(body) {
return InputEvent{}, ErrInputMalformed
}
return InputEvent{Device: "mouse-absolute", Payload: append([]byte(nil), body...)}, nil
case gatewayInputScroll:
if len(body) != 4 {
return InputEvent{}, ErrInputMalformed
}
return InputEvent{Device: "mouse-scroll", Payload: append([]byte(nil), body...)}, nil
default:
return InputEvent{}, ErrInputMalformed
}
}
func validAbsolutePayload(payload []byte) bool {
if len(payload) != 8 {
return false
}
x, y := binary.BigEndian.Uint16(payload[:2]), binary.BigEndian.Uint16(payload[2:4])
width, height := binary.BigEndian.Uint16(payload[4:6]), binary.BigEndian.Uint16(payload[6:8])
return width != 0 && height != 0 && x < width && y < height
}
func anyNonzero(data []byte) bool {
for _, value := range data {
if value != 0 {
+11
View File
@@ -68,6 +68,7 @@ type ServerConfig struct {
TLSConfig *tls.Config
QUICConfig *quic.Config
GatewayID string
Features []string
Capabilities protocol.CapabilityProfile
ProviderCapabilities protocol.CapabilityProfile
Admission Admission
@@ -866,6 +867,16 @@ func (s *gatewaySession) handleInput(payload []byte, sequence uint32) error {
if err != nil {
return err
}
requiredFeature := ""
switch event.Device {
case "mouse-absolute":
requiredFeature = "input.absolute.v1"
case "mouse-scroll":
requiredFeature = "input.scroll.v1"
}
if requiredFeature != "" && !slices.Contains(s.server.config.Features, requiredFeature) {
return ErrInputMalformed
}
event.Sequence = sequence
if err := s.provider.Input(s.ctx, event); err != nil {
s.server.metrics.InputRejected.Add(1)