diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index e6ddda7..26112fb 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -16,7 +16,10 @@ linked, or embedded: - Apollo `adc5c5a0bd80831ce495434bb16aee2cd4175fb8`, GPLv3: `src/rtsp.cpp`, `src/stream.cpp`, `src/audio.cpp`, `src/audio.h`, - `src/nvhttp.cpp`, `LICENSE`, and `NOTICE`. + `src/nvhttp.cpp`, `src/input.cpp`, `LICENSE`, and `NOTICE`. +- Apollo's moonlight-common-c pin + `c999436858471dfefa7617af3b7dc03ec1644ce4`, GPLv3: `src/Input.h`, + `src/InputStream.c`, and `LICENSE.txt`. - Moonlight Qt `c0c4d6056569bba40ac4458a3c225c05ff86df6d` with common-c pin `2ea47752c3051d72a64bcca190024e8b354fa1ef`, GPLv3: `src/ControlStream.c`, `src/Video.h`, `src/RtpAudioQueue.h`, diff --git a/cmd/verse-gateway/main.go b/cmd/verse-gateway/main.go index 8c8505b..a2e4fa6 100644 --- a/cmd/verse-gateway/main.go +++ b/cmd/verse-gateway/main.go @@ -57,11 +57,12 @@ func run() error { controlPlaneClient := gateway.NewControlPlaneClient(controlPlane, &http.Client{Transport: transport, Timeout: 5 * time.Second}) provider := gateway.NewApolloAdapter(gateway.NewNativeApolloBackend(), gateway.ProviderIdentity{}) capabilities := gateway.DefaultCapabilities() - server, err := gateway.NewServer(gateway.ServerConfig{ListenAddress: listen, TLSConfig: serverTLS, GatewayID: gatewayID, Capabilities: capabilities, ProviderCapabilities: capabilities, Admission: controlPlaneClient, ProviderStateReporter: controlPlaneClient, ClipboardAuditReporter: controlPlaneClient, Provider: provider, PacerKbps: 100000}) + features := gateway.DefaultFeatures() + server, err := gateway.NewServer(gateway.ServerConfig{ListenAddress: listen, TLSConfig: serverTLS, GatewayID: gatewayID, Features: features, Capabilities: capabilities, ProviderCapabilities: capabilities, Admission: controlPlaneClient, ProviderStateReporter: controlPlaneClient, ClipboardAuditReporter: controlPlaneClient, Provider: provider, PacerKbps: 100000}) if err != nil { return err } - registration := protocol.GatewayRegistration{Version: "1", GatewayID: gatewayID, InstanceIdentity: instanceIdentity, CertificateIdentity: certificateIdentity, PublicIdentity: publicIdentity, Address: advertiseAddress, ProviderIdentity: "server-derived", ProtocolMinVersion: 1, ProtocolMaxVersion: 1, ConnectionCapacity: 8, BandwidthCapacityKbps: 100000, Features: []string{"quic-tls13", "datagram.media", "apollo"}, Capabilities: capabilities} + registration := protocol.GatewayRegistration{Version: "1", GatewayID: gatewayID, InstanceIdentity: instanceIdentity, CertificateIdentity: certificateIdentity, PublicIdentity: publicIdentity, Address: advertiseAddress, ProviderIdentity: "server-derived", ProtocolMinVersion: 1, ProtocolMaxVersion: 1, ConnectionCapacity: 8, BandwidthCapacityKbps: 100000, Features: features, Capabilities: capabilities} if _, err := controlPlaneClient.Register(context.Background(), registration); err != nil { _ = server.Close() return err diff --git a/gateway/apollo_enet_test.go b/gateway/apollo_enet_test.go index 9d49fa6..64578c6 100644 --- a/gateway/apollo_enet_test.go +++ b/gateway/apollo_enet_test.go @@ -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) + } } } diff --git a/gateway/apollo_input.go b/gateway/apollo_input.go index 591fd54..777082f 100644 --- a/gateway/apollo_input.go +++ b/gateway/apollo_input.go @@ -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 } } diff --git a/gateway/apollo_native.go b/gateway/apollo_native.go index 4b49165..32ee510 100644 --- a/gateway/apollo_native.go +++ b/gateway/apollo_native.go @@ -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) diff --git a/gateway/capability.go b/gateway/capability.go index 41192df..e0b99e7 100644 --- a/gateway/capability.go +++ b/gateway/capability.go @@ -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", diff --git a/gateway/gateway_test.go b/gateway/gateway_test.go index 1ebb089..d58043d 100644 --- a/gateway/gateway_test.go +++ b/gateway/gateway_test.go @@ -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) } diff --git a/gateway/input.go b/gateway/input.go index 023cb71..eeedad7 100644 --- a/gateway/input.go +++ b/gateway/input.go @@ -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 { diff --git a/gateway/transport.go b/gateway/transport.go index 2a4bad8..0a66695 100644 --- a/gateway/transport.go +++ b/gateway/transport.go @@ -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) diff --git a/openspec/changes/phase-3d-macos-native-mvp/tasks.md b/openspec/changes/phase-3d-macos-native-mvp/tasks.md index ac56cee..0c69799 100644 --- a/openspec/changes/phase-3d-macos-native-mvp/tasks.md +++ b/openspec/changes/phase-3d-macos-native-mvp/tasks.md @@ -1,22 +1,22 @@ ## 1. Contract Freeze -- [ ] 1.1 Add Protocol `DisplayMode`, negotiated display fields/features, VGI1 absolute/scroll grammar, strict valid/invalid cross-language fixtures, and optional-field omission regressions -- [ ] 1.2 Regenerate Go/Rust/Swift outputs twice, pass Protocol `make verify`, freeze a verified-unused immutable Phase 3D RC, and record its fixture/generated hashes -- [ ] 1.3 Update Server and Data Plane to the exact Protocol RC without a filesystem replacement and prove clean-cache module resolution before consumer implementation +- [x] 1.1 Add Protocol `DisplayMode`, negotiated display fields/features, VGI1 absolute/scroll grammar, strict valid/invalid cross-language fixtures, and optional-field omission regressions +- [x] 1.2 Regenerate Go/Rust/Swift outputs twice, pass Protocol `make verify`, freeze a verified-unused immutable Phase 3D RC, and record its fixture/generated hashes +- [x] 1.3 Update Server and Data Plane to the exact Protocol RC without a filesystem replacement and prove clean-cache module resolution before consumer implementation ## 2. Server Display Authority -- [ ] 2.1 Add the forward-only nullable requested/effective display and policy-version migration plus clean-install/upgrade/schema/grant tests -- [ ] 2.2 Add strict request validation and requested-mode idempotency identity, including waiting-session mismatch regressions -- [ ] 2.3 Implement one proportional even-pixel/FPS clamp at allocation and atomically persist the immutable requested/effective decision -- [ ] 2.4 Disclose display-aware broker/manifest values only to negotiated clients, preserve legacy response shapes, and reuse the persisted mode on reconnect -- [ ] 2.5 Project persisted effective width/height/FPS through existing provider work with selected immutable codec/bitrate/audio and pass focused repository/E2E tests +- [x] 2.1 Add the forward-only nullable requested/effective display and policy-version migration plus clean-install/upgrade/schema/grant tests +- [x] 2.2 Add strict request validation and requested-mode idempotency identity, including waiting-session mismatch regressions +- [x] 2.3 Implement one proportional even-pixel/FPS clamp at allocation and atomically persist the immutable requested/effective decision +- [x] 2.4 Disclose display-aware broker/manifest values only to negotiated clients, preserve legacy response shapes, and reuse the persisted mode on reconnect +- [x] 2.5 Project persisted effective width/height/FPS through existing provider work with selected immutable codec/bitrate/audio and pass focused repository/E2E tests ## 3. Gateway Input Translation -- [ ] 3.1 Add red VGI absolute/scroll encode/decode/bounds/fuzz/transport tests and prove unadvertised kinds fail before provider translation -- [ ] 3.2 Establish exact Apollo absolute-pointer and scroll vectors from the approved pinned source; hard-stop without that evidence -- [ ] 3.3 Implement the smallest provider-neutral VGI validation and Apollo adapter translation without adding pressed-state or direct-provider surfaces +- [x] 3.1 Add red VGI absolute/scroll encode/decode/bounds/fuzz/transport tests and prove unadvertised kinds fail before provider translation +- [x] 3.2 Establish exact Apollo absolute-pointer and scroll vectors from the approved pinned source; hard-stop without that evidence +- [x] 3.3 Implement the smallest provider-neutral VGI validation and Apollo adapter translation without adding pressed-state or direct-provider surfaces - [ ] 3.4 Pass focused input vectors/fuzz/transport/fake-provider tests, then one affected Data Plane `make verify` ## 4. Rust Core and Stable ABI