From 408d4f9cc33d59cb1ec52d509ff701b5d28e5178 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:07:04 +0700 Subject: [PATCH] feat(protocol): negotiate display and native input --- .../conformance/gateway-input-feedback-v1.tsv | 7 + fixtures/conformance/tunnel-v1.tsv | 3 + fixtures/manifest.json | 2 +- frames/gateway-input-feedback-v1.md | 22 ++- gen/go/protocol/protocol.go | 166 +++++++++++++++--- gen/manifest.json | 4 +- gen/rust/protocol.rs | 61 ++++++- gen/swift/Protocol.swift | 75 +++++++- .../.openspec.yaml | 2 + .../phase-3d-client-display-input/design.md | 54 ++++++ .../phase-3d-client-display-input/proposal.md | 38 ++++ .../specs/gateway-input-feedback/spec.md | 33 ++++ .../specs/session-display-mode/spec.md | 35 ++++ .../phase-3d-client-display-input/tasks.md | 17 ++ registries/features.json | 5 +- schemas/control-v1.schema.json | 20 ++- tests/go/protocol_test.go | 78 ++++++++ tools/generate.py | 18 +- tools/go-conformance/main.go | 19 +- tools/native_conformance.rs | 14 +- tools/native_conformance.swift | 13 +- tools/test_generated_contracts.py | 56 ++++++ tools/validate.py | 18 ++ tools/validate_gateway_envelopes.py | 7 + 24 files changed, 707 insertions(+), 60 deletions(-) create mode 100644 openspec/changes/phase-3d-client-display-input/.openspec.yaml create mode 100644 openspec/changes/phase-3d-client-display-input/design.md create mode 100644 openspec/changes/phase-3d-client-display-input/proposal.md create mode 100644 openspec/changes/phase-3d-client-display-input/specs/gateway-input-feedback/spec.md create mode 100644 openspec/changes/phase-3d-client-display-input/specs/session-display-mode/spec.md create mode 100644 openspec/changes/phase-3d-client-display-input/tasks.md diff --git a/fixtures/conformance/gateway-input-feedback-v1.tsv b/fixtures/conformance/gateway-input-feedback-v1.tsv index 8389d74..686f5ac 100644 --- a/fixtures/conformance/gateway-input-feedback-v1.tsv +++ b/fixtures/conformance/gateway-input-feedback-v1.tsv @@ -7,6 +7,8 @@ valid-relative-mouse 1 gateway_input hex=564749310304fffe0003 valid valid-utf8-scalar 1 gateway_input hex=564749310403e29883 valid valid-controller 1 gateway_input hex=5647493105110200030004ffff00010002000300040005 valid valid-controller-release 1 gateway_input hex=5647493105110200000000000000000000000000000000 valid +valid-absolute-mouse 1 gateway_input hex=56474931060804d202370a0005a0 valid +valid-scroll 1 gateway_input hex=564749310704ff880078 valid valid-idr 1 gateway_feedback hex=5647463100010000 valid valid-fec 1 gateway_feedback hex=56474631000200150000002a000500030002000a000200080002140001 valid valid-terminal-receipt 1 gateway_feedback hex=5647463100030000 valid @@ -18,6 +20,11 @@ invalid-input-kind 1 gateway_input hex=564749317f00 invalid:kind invalid-input-reserved 1 gateway_input hex=564749310203010101 invalid:reserved invalid-input-utf8 1 gateway_input hex=564749310402c328 invalid:utf8 invalid-input-length 1 gateway_input hex=564749310104010200 invalid:length +invalid-absolute-zero-viewport 1 gateway_input hex=56474931060800000000000005a0 invalid:field +invalid-absolute-x-out-of-range 1 gateway_input hex=5647493106080a0000000a0005a0 invalid:field +invalid-absolute-y-out-of-range 1 gateway_input hex=564749310608000005a00a0005a0 invalid:field +invalid-absolute-length 1 gateway_input hex=56474931060700000000010001 invalid:length +invalid-scroll-length 1 gateway_input hex=5647493107020000 invalid:length invalid-feedback-direction 1 gateway_feedback hex=5647463101020000 invalid:direction invalid-terminal-receipt-direction 1 gateway_feedback hex=5647463101030000 invalid:direction invalid-terminal-receipt-body 1 gateway_feedback hex=5647463100030001ff invalid:length diff --git a/fixtures/conformance/tunnel-v1.tsv b/fixtures/conformance/tunnel-v1.tsv index c4cd4cb..dfbbc31 100644 --- a/fixtures/conformance/tunnel-v1.tsv +++ b/fixtures/conformance/tunnel-v1.tsv @@ -2,5 +2,8 @@ id version kind input expected tunnel-current 1 tunnel offered=1;feature=control.v1 valid tunnel-n-minus-1 0 tunnel offered=0;feature=control.v1 valid tunnel-n-minus-2 -1 tunnel offered=-1;feature=control.v1 valid +tunnel-display-request 1 tunnel offered=1;feature=display.request.v1 valid +tunnel-absolute-input 1 tunnel offered=1;feature=input.absolute.v1 valid +tunnel-scroll-input 1 tunnel offered=1;feature=input.scroll.v1 valid tunnel-unsupported 1 tunnel offered=2;feature=control.v1 invalid:unsupported_version tunnel-no-control 1 tunnel offered=1;feature=media.video invalid:unsupported_feature diff --git a/fixtures/manifest.json b/fixtures/manifest.json index 9b2dabe..74aa16f 100644 --- a/fixtures/manifest.json +++ b/fixtures/manifest.json @@ -10,5 +10,5 @@ "fixtures/conformance/gateway-input-feedback-v1.tsv", "fixtures/conformance/tunnel-v1.tsv" ], - "corpus_sha256": "544ba8765b4ddcfbe807d1d6e6788fe03d06b8e50d1424b82e4e512312e51248" + "corpus_sha256": "4f8bf520d4f61588235b00776b21202ff701b20d6d11b461037b911c2117852b" } diff --git a/frames/gateway-input-feedback-v1.md b/frames/gateway-input-feedback-v1.md index 838d561..d50903f 100644 --- a/frames/gateway-input-feedback-v1.md +++ b/frames/gateway-input-feedback-v1.md @@ -4,12 +4,14 @@ This grammar is carried only in an authenticated Phase 3C gateway session. It is deliberately provider-neutral: it never carries provider routes, certificates, credentials, opaque provider packets, clipboard bytes, files, or client-folder data. It does not change the v1 datagram header or any existing -release candidate. +kind encoding; the new kinds require explicit feature negotiation and a new +immutable Protocol release candidate. ## `input.sequenced.v1` payload (`VGI1`) -All multibyte fields are unsigned big-endian. The payload has exactly six bytes -of header followed by the declared body: +All multibyte fields are big-endian and unsigned unless a field is explicitly +marked signed. The payload has exactly six bytes of header followed by the +declared body: | Offset | Size | Field | Rule | |---:|---:|---|---| @@ -32,12 +34,16 @@ during cleanup. | `0x03` | relative mouse | `delta_x` (i16), `delta_y` (i16). | | `0x04` | UTF-8 scalar | exactly one valid UTF-8 Unicode scalar, one through four bytes. | | `0x05` | controller state | `controller` (0 through 15), `active_mask` (u16), `button_flags` (u16), `left_trigger` (u8), `right_trigger` (u8), `left_x` (i16), `left_y` (i16), `right_x` (i16), `right_y` (i16), `extra_button_flags` (u16). A zero `active_mask` and zero state is release. | +| `0x06` | absolute mouse | `x` (u16), `y` (u16), `viewport_width` (u16), `viewport_height` (u16). Viewport dimensions must be nonzero and each coordinate must be strictly smaller than its corresponding dimension. | +| `0x07` | high-resolution scroll | `vertical_delta` (i16), `horizontal_delta` (i16). Positive and negative values preserve the client scroll direction without provider-specific scaling. | -Keyboard, mouse button, UTF-8, and controller messages are delivered over the -gateway's reliable ordered input flow. Relative mouse is a state change, not a -pressed-state entry. The gateway maps the validated values to the provider's -separate keyboard, mouse, UTF-8, and controller control messages; it does not -forward this envelope to the provider. +Keyboard, mouse button, UTF-8, controller, absolute mouse, and scroll messages +are delivered over the gateway's reliable ordered input flow. Relative and +absolute mouse movement and scroll are state changes, not pressed-state +entries. Kinds `0x06` and `0x07` are accepted only when the session advertises +`input.absolute.v1` and `input.scroll.v1`, respectively. The gateway maps the +validated values to the provider's separate input messages; it does not forward +this envelope to the provider. ## Reliable control payload (`VGF1`) diff --git a/gen/go/protocol/protocol.go b/gen/go/protocol/protocol.go index 238b2f1..5c0a34c 100644 --- a/gen/go/protocol/protocol.go +++ b/gen/go/protocol/protocol.go @@ -13,7 +13,7 @@ import ( "time" ) -const SchemaSHA256 = "a86cbdaf2cfb884e3d98467968007e731ca55c6f6eb6dbd5cd6b95e062a9b058" +const SchemaSHA256 = "b2bb0a8ac8ef56dbc0e1443eeb5b3028be9e71ec2f5fd8e73928d71b7cd9340c" const ProtocolVersion = "1.0.0" const CurrentWireVersion = "1" const NMinus1WireVersion = "0" @@ -50,21 +50,23 @@ type AssignedDesktop struct { } type BrokerSession struct { - ID string `json:"id"` - PrincipalID string `json:"principal_id"` - PoolID string `json:"pool_id"` - AssignmentID string `json:"assignment_id,omitempty"` - State string `json:"state"` - PolicySnapshot AllocationPolicy `json:"policy_snapshot"` - ReconnectDeadline string `json:"reconnect_deadline,omitempty"` - Outcome string `json:"outcome,omitempty"` - FailureCode string `json:"failure_code,omitempty"` - CleanupState string `json:"cleanup_state"` - IdempotencyKey string `json:"idempotency_key"` - CorrelationID string `json:"correlation_id"` - RequestedAt string `json:"requested_at"` - EndedAt string `json:"ended_at,omitempty"` - Version int64 `json:"version"` + ID string `json:"id"` + PrincipalID string `json:"principal_id"` + PoolID string `json:"pool_id"` + AssignmentID string `json:"assignment_id,omitempty"` + State string `json:"state"` + PolicySnapshot AllocationPolicy `json:"policy_snapshot"` + ReconnectDeadline string `json:"reconnect_deadline,omitempty"` + Outcome string `json:"outcome,omitempty"` + FailureCode string `json:"failure_code,omitempty"` + CleanupState string `json:"cleanup_state"` + IdempotencyKey string `json:"idempotency_key"` + CorrelationID string `json:"correlation_id"` + RequestedAt string `json:"requested_at"` + EndedAt string `json:"ended_at,omitempty"` + Version int64 `json:"version"` + RequestedDisplayMode *DisplayMode `json:"requested_display_mode,omitempty"` + EffectiveDisplayMode *DisplayMode `json:"effective_display_mode,omitempty"` } type CapabilityProfile struct { @@ -134,6 +136,12 @@ type DeviceRegistrationRequest struct { PublicKey string `json:"public_key"` } +type DisplayMode struct { + ResolutionWidth int64 `json:"resolution_width"` + ResolutionHeight int64 `json:"resolution_height"` + Fps int64 `json:"fps"` +} + type EntitledPool struct { PoolID string `json:"pool_id"` Name string `json:"name"` @@ -263,8 +271,9 @@ type ManifestGateway struct { } type ManifestProfile struct { - ID string `json:"id"` - Bounds ManifestBounds `json:"bounds"` + ID string `json:"id"` + Bounds ManifestBounds `json:"bounds"` + DisplayMode *DisplayMode `json:"display_mode,omitempty"` } type ManifestTunnel struct { @@ -383,11 +392,12 @@ type SessionAuthority struct { } type SessionRequest struct { - ClientDeviceID string `json:"client_device_id"` - DeviceKeyID string `json:"device_key_id"` - PoolID string `json:"pool_id"` - IdempotencyKey string `json:"idempotency_key"` - PolicySnapshot AllocationPolicy `json:"policy_snapshot"` + ClientDeviceID string `json:"client_device_id"` + DeviceKeyID string `json:"device_key_id"` + PoolID string `json:"pool_id"` + IdempotencyKey string `json:"idempotency_key"` + PolicySnapshot AllocationPolicy `json:"policy_snapshot"` + RequestedDisplayMode *DisplayMode `json:"requested_display_mode,omitempty"` } type StableError struct { @@ -767,6 +777,16 @@ func (v BrokerSession) Validate() error { if v.Version != 0 && v.Version < 1 { violations = append(violations, FieldViolation{Field: "version", Code: "minimum"}) } + if v.RequestedDisplayMode != nil { + if err := v.RequestedDisplayMode.Validate(); err != nil { + violations = append(violations, FieldViolation{Field: "requested_display_mode", Code: "invalid_object"}) + } + } + if v.EffectiveDisplayMode != nil { + if err := v.EffectiveDisplayMode.Validate(); err != nil { + violations = append(violations, FieldViolation{Field: "effective_display_mode", Code: "invalid_object"}) + } + } if len(violations) > 0 { return ValidationError{Violations: violations} } @@ -812,6 +832,12 @@ func DecodeBrokerSession(data []byte) (BrokerSession, error) { if raw, ok := fields["version"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { return value, ValidationError{Violations: []FieldViolation{{Field: "version", Code: "required"}}} } + if raw, ok := fields["requested_display_mode"]; ok && bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "requested_display_mode", Code: "invalid_object"}}} + } + if raw, ok := fields["effective_display_mode"]; ok && bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "effective_display_mode", Code: "invalid_object"}}} + } decoder := json.NewDecoder(bytes.NewReader(data)) decoder.DisallowUnknownFields() if err := decoder.Decode(&value); err != nil { @@ -1625,6 +1651,84 @@ func EncodeDeviceRegistrationRequest(value DeviceRegistrationRequest) ([]byte, e return json.Marshal(value) } +func (v DisplayMode) Validate() error { + var violations []FieldViolation + if v.ResolutionWidth == 0 { + violations = append(violations, FieldViolation{Field: "resolution_width", Code: "required"}) + } + if v.ResolutionWidth != 0 && v.ResolutionWidth < 320 { + violations = append(violations, FieldViolation{Field: "resolution_width", Code: "minimum"}) + } + if v.ResolutionWidth > 16384 { + violations = append(violations, FieldViolation{Field: "resolution_width", Code: "maximum"}) + } + if v.ResolutionHeight == 0 { + violations = append(violations, FieldViolation{Field: "resolution_height", Code: "required"}) + } + if v.ResolutionHeight != 0 && v.ResolutionHeight < 200 { + violations = append(violations, FieldViolation{Field: "resolution_height", Code: "minimum"}) + } + if v.ResolutionHeight > 8640 { + violations = append(violations, FieldViolation{Field: "resolution_height", Code: "maximum"}) + } + if v.Fps == 0 { + violations = append(violations, FieldViolation{Field: "fps", Code: "required"}) + } + if v.Fps != 0 && v.Fps < 1 { + violations = append(violations, FieldViolation{Field: "fps", Code: "minimum"}) + } + if v.Fps > 240 { + violations = append(violations, FieldViolation{Field: "fps", Code: "maximum"}) + } + if len(violations) > 0 { + return ValidationError{Violations: violations} + } + return nil +} + +func DecodeDisplayMode(data []byte) (DisplayMode, error) { + var value DisplayMode + if len(data) > 1024*1024 { + return value, errors.New("protocol payload exceeds limit") + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return value, err + } + if raw, ok := fields["fps"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "fps", Code: "required"}}} + } + if raw, ok := fields["resolution_height"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "resolution_height", Code: "required"}}} + } + if raw, ok := fields["resolution_width"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "resolution_width", Code: "required"}}} + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil { + return value, err + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return value, errors.New("trailing JSON value") + } + return value, err + } + if err := value.Validate(); err != nil { + return value, err + } + return value, nil +} + +func EncodeDisplayMode(value DisplayMode) ([]byte, error) { + if err := value.Validate(); err != nil { + return nil, err + } + return json.Marshal(value) +} + func (v EntitledPool) Validate() error { var violations []FieldViolation if v.PoolID == "" { @@ -3202,6 +3306,11 @@ func (v ManifestProfile) Validate() error { if err := v.Bounds.Validate(); err != nil { violations = append(violations, FieldViolation{Field: "bounds", Code: "invalid_object"}) } + if v.DisplayMode != nil { + if err := v.DisplayMode.Validate(); err != nil { + violations = append(violations, FieldViolation{Field: "display_mode", Code: "invalid_object"}) + } + } if len(violations) > 0 { return ValidationError{Violations: violations} } @@ -3223,6 +3332,9 @@ func DecodeManifestProfile(data []byte) (ManifestProfile, error) { if raw, ok := fields["id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { return value, ValidationError{Violations: []FieldViolation{{Field: "id", Code: "required"}}} } + if raw, ok := fields["display_mode"]; ok && bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "display_mode", Code: "invalid_object"}}} + } decoder := json.NewDecoder(bytes.NewReader(data)) decoder.DisallowUnknownFields() if err := decoder.Decode(&value); err != nil { @@ -4655,6 +4767,11 @@ func (v SessionRequest) Validate() error { if err := v.PolicySnapshot.Validate(); err != nil { violations = append(violations, FieldViolation{Field: "policy_snapshot", Code: "invalid_object"}) } + if v.RequestedDisplayMode != nil { + if err := v.RequestedDisplayMode.Validate(); err != nil { + violations = append(violations, FieldViolation{Field: "requested_display_mode", Code: "invalid_object"}) + } + } if len(violations) > 0 { return ValidationError{Violations: violations} } @@ -4685,6 +4802,9 @@ func DecodeSessionRequest(data []byte) (SessionRequest, error) { if raw, ok := fields["pool_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { return value, ValidationError{Violations: []FieldViolation{{Field: "pool_id", Code: "required"}}} } + if raw, ok := fields["requested_display_mode"]; ok && bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "requested_display_mode", Code: "invalid_object"}}} + } decoder := json.NewDecoder(bytes.NewReader(data)) decoder.DisallowUnknownFields() if err := decoder.Decode(&value); err != nil { diff --git a/gen/manifest.json b/gen/manifest.json index 8476f14..b7ead5c 100644 --- a/gen/manifest.json +++ b/gen/manifest.json @@ -12,7 +12,7 @@ "2" ] }, - "generator_sha256": "00fdba050eb924a54dd3d63aac0a38560341b675f0de4e3e9631ee895057a9b6", + "generator_sha256": "992235a56d3467313148f86e47931f247591e4c8de737b55ac9c9eee35725fc5", "protocol_version": "1.0.0", - "schema_sha256": "a86cbdaf2cfb884e3d98467968007e731ca55c6f6eb6dbd5cd6b95e062a9b058" + "schema_sha256": "b2bb0a8ac8ef56dbc0e1443eeb5b3028be9e71ec2f5fd8e73928d71b7cd9340c" } diff --git a/gen/rust/protocol.rs b/gen/rust/protocol.rs index d8377b3..1b623c3 100644 --- a/gen/rust/protocol.rs +++ b/gen/rust/protocol.rs @@ -1,6 +1,6 @@ // Code generated by tools/generate.py; DO NOT EDIT. #![allow(non_snake_case)] -pub const SCHEMA_SHA256: &str = "a86cbdaf2cfb884e3d98467968007e731ca55c6f6eb6dbd5cd6b95e062a9b058"; +pub const SCHEMA_SHA256: &str = "b2bb0a8ac8ef56dbc0e1443eeb5b3028be9e71ec2f5fd8e73928d71b7cd9340c"; pub const CURRENT_WIRE_VERSION: &str = "1"; pub const N_MINUS_1_WIRE_VERSION: &str = "0"; pub const N_MINUS_2_WIRE_VERSION: &str = "-1"; @@ -136,11 +136,13 @@ pub struct BrokerSession { requestedAt: String, endedAt: Option, version: i64, + requestedDisplayMode: Option, + effectiveDisplayMode: Option, } impl BrokerSession { - pub fn new(id: String, principalId: String, poolId: String, assignmentId: Option, state: String, policySnapshot: AllocationPolicy, reconnectDeadline: Option, outcome: Option, failureCode: Option, cleanupState: String, idempotencyKey: String, correlationId: String, requestedAt: String, endedAt: Option, version: i64) -> Result { - let value = Self { id, principalId, poolId, assignmentId, state, policySnapshot, reconnectDeadline, outcome, failureCode, cleanupState, idempotencyKey, correlationId, requestedAt, endedAt, version }; + pub fn new(id: String, principalId: String, poolId: String, assignmentId: Option, state: String, policySnapshot: AllocationPolicy, reconnectDeadline: Option, outcome: Option, failureCode: Option, cleanupState: String, idempotencyKey: String, correlationId: String, requestedAt: String, endedAt: Option, version: i64, requestedDisplayMode: Option, effectiveDisplayMode: Option) -> Result { + let value = Self { id, principalId, poolId, assignmentId, state, policySnapshot, reconnectDeadline, outcome, failureCode, cleanupState, idempotencyKey, correlationId, requestedAt, endedAt, version, requestedDisplayMode, effectiveDisplayMode }; value.validate()?; Ok(value) } @@ -184,6 +186,12 @@ impl BrokerSession { if value.len() > 64 { return Err(ValidationError::new("ended_at", "max_length")); } } if self.version < 1 { return Err(ValidationError::new("version", "minimum")); } + if let Some(value) = &self.requestedDisplayMode { + value.validate().map_err(|_| ValidationError::new("requested_display_mode", "invalid_object"))?; + } + if let Some(value) = &self.effectiveDisplayMode { + value.validate().map_err(|_| ValidationError::new("effective_display_mode", "invalid_object"))?; + } Ok(()) } pub fn id(&self) -> &String { &self.id } @@ -201,6 +209,8 @@ impl BrokerSession { pub fn requestedAt(&self) -> &String { &self.requestedAt } pub fn endedAt(&self) -> &Option { &self.endedAt } pub fn version(&self) -> &i64 { &self.version } + pub fn requestedDisplayMode(&self) -> &Option { &self.requestedDisplayMode } + pub fn effectiveDisplayMode(&self) -> &Option { &self.effectiveDisplayMode } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -494,6 +504,33 @@ impl DeviceRegistrationRequest { pub fn publicKey(&self) -> &String { &self.publicKey } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DisplayMode { + resolutionWidth: i64, + resolutionHeight: i64, + fps: i64, +} + +impl DisplayMode { + pub fn new(resolutionWidth: i64, resolutionHeight: i64, fps: i64) -> Result { + let value = Self { resolutionWidth, resolutionHeight, fps }; + value.validate()?; + Ok(value) + } + pub fn validate(&self) -> Result<(), ValidationError> { + if self.resolutionWidth < 320 { return Err(ValidationError::new("resolution_width", "minimum")); } + if self.resolutionWidth > 16384 { return Err(ValidationError::new("resolution_width", "maximum")); } + if self.resolutionHeight < 200 { return Err(ValidationError::new("resolution_height", "minimum")); } + if self.resolutionHeight > 8640 { return Err(ValidationError::new("resolution_height", "maximum")); } + if self.fps < 1 { return Err(ValidationError::new("fps", "minimum")); } + if self.fps > 240 { return Err(ValidationError::new("fps", "maximum")); } + Ok(()) + } + pub fn resolutionWidth(&self) -> &i64 { &self.resolutionWidth } + pub fn resolutionHeight(&self) -> &i64 { &self.resolutionHeight } + pub fn fps(&self) -> &i64 { &self.fps } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct EntitledPool { poolId: String, @@ -1080,11 +1117,12 @@ impl ManifestGateway { pub struct ManifestProfile { id: String, bounds: ManifestBounds, + displayMode: Option, } impl ManifestProfile { - pub fn new(id: String, bounds: ManifestBounds) -> Result { - let value = Self { id, bounds }; + pub fn new(id: String, bounds: ManifestBounds, displayMode: Option) -> Result { + let value = Self { id, bounds, displayMode }; value.validate()?; Ok(value) } @@ -1093,10 +1131,14 @@ impl ManifestProfile { if !self.id.is_empty() && self.id.len() < 1 { return Err(ValidationError::new("id", "min_length")); } if self.id.len() > 128 { return Err(ValidationError::new("id", "max_length")); } self.bounds.validate().map_err(|_| ValidationError::new("bounds", "invalid_object"))?; + if let Some(value) = &self.displayMode { + value.validate().map_err(|_| ValidationError::new("display_mode", "invalid_object"))?; + } Ok(()) } pub fn id(&self) -> &String { &self.id } pub fn bounds(&self) -> &ManifestBounds { &self.bounds } + pub fn displayMode(&self) -> &Option { &self.displayMode } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -1613,11 +1655,12 @@ pub struct SessionRequest { poolId: String, idempotencyKey: String, policySnapshot: AllocationPolicy, + requestedDisplayMode: Option, } impl SessionRequest { - pub fn new(clientDeviceId: String, deviceKeyId: String, poolId: String, idempotencyKey: String, policySnapshot: AllocationPolicy) -> Result { - let value = Self { clientDeviceId, deviceKeyId, poolId, idempotencyKey, policySnapshot }; + pub fn new(clientDeviceId: String, deviceKeyId: String, poolId: String, idempotencyKey: String, policySnapshot: AllocationPolicy, requestedDisplayMode: Option) -> Result { + let value = Self { clientDeviceId, deviceKeyId, poolId, idempotencyKey, policySnapshot, requestedDisplayMode }; value.validate()?; Ok(value) } @@ -1635,6 +1678,9 @@ impl SessionRequest { if !self.idempotencyKey.is_empty() && self.idempotencyKey.len() < 1 { return Err(ValidationError::new("idempotency_key", "min_length")); } if self.idempotencyKey.len() > 256 { return Err(ValidationError::new("idempotency_key", "max_length")); } self.policySnapshot.validate().map_err(|_| ValidationError::new("policy_snapshot", "invalid_object"))?; + if let Some(value) = &self.requestedDisplayMode { + value.validate().map_err(|_| ValidationError::new("requested_display_mode", "invalid_object"))?; + } Ok(()) } pub fn clientDeviceId(&self) -> &String { &self.clientDeviceId } @@ -1642,6 +1688,7 @@ impl SessionRequest { pub fn poolId(&self) -> &String { &self.poolId } pub fn idempotencyKey(&self) -> &String { &self.idempotencyKey } pub fn policySnapshot(&self) -> &AllocationPolicy { &self.policySnapshot } + pub fn requestedDisplayMode(&self) -> &Option { &self.requestedDisplayMode } } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/gen/swift/Protocol.swift b/gen/swift/Protocol.swift index 01a7bc0..700171d 100644 --- a/gen/swift/Protocol.swift +++ b/gen/swift/Protocol.swift @@ -1,7 +1,7 @@ // Code generated by tools/generate.py; DO NOT EDIT. import Foundation public typealias JSONObject = [String: String] -public let schemaSHA256 = "a86cbdaf2cfb884e3d98467968007e731ca55c6f6eb6dbd5cd6b95e062a9b058" +public let schemaSHA256 = "b2bb0a8ac8ef56dbc0e1443eeb5b3028be9e71ec2f5fd8e73928d71b7cd9340c" public let currentWireVersion = "1" public let nMinus1WireVersion = "0" public let nMinus2WireVersion = "-1" @@ -148,6 +148,8 @@ public struct BrokerSession: Codable, Equatable { public let requestedAt: String public let endedAt: String? public let version: Int64 + public let requestedDisplayMode: DisplayMode? + public let effectiveDisplayMode: DisplayMode? enum CodingKeys: String, CodingKey { case id = "id" case principalId = "principal_id" @@ -164,9 +166,11 @@ public struct BrokerSession: Codable, Equatable { case requestedAt = "requested_at" case endedAt = "ended_at" case version = "version" + case requestedDisplayMode = "requested_display_mode" + case effectiveDisplayMode = "effective_display_mode" } - public init(id: String, principalId: String, poolId: String, assignmentId: String?, state: String, policySnapshot: AllocationPolicy, reconnectDeadline: String?, outcome: String?, failureCode: String?, cleanupState: String, idempotencyKey: String, correlationId: String, requestedAt: String, endedAt: String?, version: Int64) throws { + public init(id: String, principalId: String, poolId: String, assignmentId: String?, state: String, policySnapshot: AllocationPolicy, reconnectDeadline: String?, outcome: String?, failureCode: String?, cleanupState: String, idempotencyKey: String, correlationId: String, requestedAt: String, endedAt: String?, version: Int64, requestedDisplayMode: DisplayMode?, effectiveDisplayMode: DisplayMode?) throws { self.id = id self.principalId = principalId self.poolId = poolId @@ -182,6 +186,8 @@ public struct BrokerSession: Codable, Equatable { self.requestedAt = requestedAt self.endedAt = endedAt self.version = version + self.requestedDisplayMode = requestedDisplayMode + self.effectiveDisplayMode = effectiveDisplayMode try validate() } @@ -189,7 +195,7 @@ public struct BrokerSession: Codable, Equatable { let all = try decoder.container(keyedBy: AnyCodingKey.self) for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") } let c = try decoder.container(keyedBy: CodingKeys.self) - try self.init(id: try c.decode(String.self, forKey: .id), principalId: try c.decode(String.self, forKey: .principalId), poolId: try c.decode(String.self, forKey: .poolId), assignmentId: try c.decodeIfPresent(String.self, forKey: .assignmentId), state: try c.decode(String.self, forKey: .state), policySnapshot: try c.decode(AllocationPolicy.self, forKey: .policySnapshot), reconnectDeadline: try c.decodeIfPresent(String.self, forKey: .reconnectDeadline), outcome: try c.decodeIfPresent(String.self, forKey: .outcome), failureCode: try c.decodeIfPresent(String.self, forKey: .failureCode), cleanupState: try c.decode(String.self, forKey: .cleanupState), idempotencyKey: try c.decode(String.self, forKey: .idempotencyKey), correlationId: try c.decode(String.self, forKey: .correlationId), requestedAt: try c.decode(String.self, forKey: .requestedAt), endedAt: try c.decodeIfPresent(String.self, forKey: .endedAt), version: try c.decode(Int64.self, forKey: .version)) + try self.init(id: try c.decode(String.self, forKey: .id), principalId: try c.decode(String.self, forKey: .principalId), poolId: try c.decode(String.self, forKey: .poolId), assignmentId: try c.decodeIfPresent(String.self, forKey: .assignmentId), state: try c.decode(String.self, forKey: .state), policySnapshot: try c.decode(AllocationPolicy.self, forKey: .policySnapshot), reconnectDeadline: try c.decodeIfPresent(String.self, forKey: .reconnectDeadline), outcome: try c.decodeIfPresent(String.self, forKey: .outcome), failureCode: try c.decodeIfPresent(String.self, forKey: .failureCode), cleanupState: try c.decode(String.self, forKey: .cleanupState), idempotencyKey: try c.decode(String.self, forKey: .idempotencyKey), correlationId: try c.decode(String.self, forKey: .correlationId), requestedAt: try c.decode(String.self, forKey: .requestedAt), endedAt: try c.decodeIfPresent(String.self, forKey: .endedAt), version: try c.decode(Int64.self, forKey: .version), requestedDisplayMode: try c.contains(.requestedDisplayMode) ? c.decode(DisplayMode.self, forKey: .requestedDisplayMode) : nil, effectiveDisplayMode: try c.contains(.effectiveDisplayMode) ? c.decode(DisplayMode.self, forKey: .effectiveDisplayMode) : nil) } public func validate() throws { @@ -235,6 +241,12 @@ public struct BrokerSession: Codable, Equatable { if ISO8601DateFormatter().date(from: value) == nil { throw ContractValidationError(field: "ended_at", code: "invalid_time") } } if self.version < 1 { throw ContractValidationError(field: "version", code: "minimum") } + if let value = self.requestedDisplayMode { + try value.validate() + } + if let value = self.effectiveDisplayMode { + try value.validate() + } } public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } @@ -632,6 +644,43 @@ public struct DeviceRegistrationRequest: Codable, Equatable { public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } +public struct DisplayMode: Codable, Equatable { + public let resolutionWidth: Int64 + public let resolutionHeight: Int64 + public let fps: Int64 + enum CodingKeys: String, CodingKey { + case resolutionWidth = "resolution_width" + case resolutionHeight = "resolution_height" + case fps = "fps" + } + + public init(resolutionWidth: Int64, resolutionHeight: Int64, fps: Int64) throws { + self.resolutionWidth = resolutionWidth + self.resolutionHeight = resolutionHeight + self.fps = fps + try validate() + } + + public init(from decoder: Decoder) throws { + let all = try decoder.container(keyedBy: AnyCodingKey.self) + for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") } + let c = try decoder.container(keyedBy: CodingKeys.self) + try self.init(resolutionWidth: try c.decode(Int64.self, forKey: .resolutionWidth), resolutionHeight: try c.decode(Int64.self, forKey: .resolutionHeight), fps: try c.decode(Int64.self, forKey: .fps)) + } + + public func validate() throws { + if self.resolutionWidth < 320 { throw ContractValidationError(field: "resolution_width", code: "minimum") } + if self.resolutionWidth > 16384 { throw ContractValidationError(field: "resolution_width", code: "maximum") } + if self.resolutionHeight < 200 { throw ContractValidationError(field: "resolution_height", code: "minimum") } + if self.resolutionHeight > 8640 { throw ContractValidationError(field: "resolution_height", code: "maximum") } + if self.fps < 1 { throw ContractValidationError(field: "fps", code: "minimum") } + if self.fps > 240 { throw ContractValidationError(field: "fps", code: "maximum") } + } + + public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } +} + public struct EntitledPool: Codable, Equatable { public let poolId: String public let name: String @@ -1414,14 +1463,17 @@ public struct ManifestGateway: Codable, Equatable { public struct ManifestProfile: Codable, Equatable { public let id: String public let bounds: ManifestBounds + public let displayMode: DisplayMode? enum CodingKeys: String, CodingKey { case id = "id" case bounds = "bounds" + case displayMode = "display_mode" } - public init(id: String, bounds: ManifestBounds) throws { + public init(id: String, bounds: ManifestBounds, displayMode: DisplayMode?) throws { self.id = id self.bounds = bounds + self.displayMode = displayMode try validate() } @@ -1429,7 +1481,7 @@ public struct ManifestProfile: Codable, Equatable { let all = try decoder.container(keyedBy: AnyCodingKey.self) for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") } let c = try decoder.container(keyedBy: CodingKeys.self) - try self.init(id: try c.decode(String.self, forKey: .id), bounds: try c.decode(ManifestBounds.self, forKey: .bounds)) + try self.init(id: try c.decode(String.self, forKey: .id), bounds: try c.decode(ManifestBounds.self, forKey: .bounds), displayMode: try c.contains(.displayMode) ? c.decode(DisplayMode.self, forKey: .displayMode) : nil) } public func validate() throws { @@ -1437,6 +1489,9 @@ public struct ManifestProfile: Codable, Equatable { if !self.id.isEmpty && self.id.utf8.count < 1 { throw ContractValidationError(field: "id", code: "min_length") } if self.id.utf8.count > 128 { throw ContractValidationError(field: "id", code: "max_length") } try self.bounds.validate() + if let value = self.displayMode { + try value.validate() + } } public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } @@ -2132,20 +2187,23 @@ public struct SessionRequest: Codable, Equatable { public let poolId: String public let idempotencyKey: String public let policySnapshot: AllocationPolicy + public let requestedDisplayMode: DisplayMode? enum CodingKeys: String, CodingKey { case clientDeviceId = "client_device_id" case deviceKeyId = "device_key_id" case poolId = "pool_id" case idempotencyKey = "idempotency_key" case policySnapshot = "policy_snapshot" + case requestedDisplayMode = "requested_display_mode" } - public init(clientDeviceId: String, deviceKeyId: String, poolId: String, idempotencyKey: String, policySnapshot: AllocationPolicy) throws { + public init(clientDeviceId: String, deviceKeyId: String, poolId: String, idempotencyKey: String, policySnapshot: AllocationPolicy, requestedDisplayMode: DisplayMode?) throws { self.clientDeviceId = clientDeviceId self.deviceKeyId = deviceKeyId self.poolId = poolId self.idempotencyKey = idempotencyKey self.policySnapshot = policySnapshot + self.requestedDisplayMode = requestedDisplayMode try validate() } @@ -2153,7 +2211,7 @@ public struct SessionRequest: Codable, Equatable { let all = try decoder.container(keyedBy: AnyCodingKey.self) for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") } let c = try decoder.container(keyedBy: CodingKeys.self) - try self.init(clientDeviceId: try c.decode(String.self, forKey: .clientDeviceId), deviceKeyId: try c.decode(String.self, forKey: .deviceKeyId), poolId: try c.decode(String.self, forKey: .poolId), idempotencyKey: try c.decode(String.self, forKey: .idempotencyKey), policySnapshot: try c.decode(AllocationPolicy.self, forKey: .policySnapshot)) + try self.init(clientDeviceId: try c.decode(String.self, forKey: .clientDeviceId), deviceKeyId: try c.decode(String.self, forKey: .deviceKeyId), poolId: try c.decode(String.self, forKey: .poolId), idempotencyKey: try c.decode(String.self, forKey: .idempotencyKey), policySnapshot: try c.decode(AllocationPolicy.self, forKey: .policySnapshot), requestedDisplayMode: try c.contains(.requestedDisplayMode) ? c.decode(DisplayMode.self, forKey: .requestedDisplayMode) : nil) } public func validate() throws { @@ -2170,6 +2228,9 @@ public struct SessionRequest: Codable, Equatable { if !self.idempotencyKey.isEmpty && self.idempotencyKey.utf8.count < 1 { throw ContractValidationError(field: "idempotency_key", code: "min_length") } if self.idempotencyKey.utf8.count > 256 { throw ContractValidationError(field: "idempotency_key", code: "max_length") } try self.policySnapshot.validate() + if let value = self.requestedDisplayMode { + try value.validate() + } } public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } diff --git a/openspec/changes/phase-3d-client-display-input/.openspec.yaml b/openspec/changes/phase-3d-client-display-input/.openspec.yaml new file mode 100644 index 0000000..d7bc011 --- /dev/null +++ b/openspec/changes/phase-3d-client-display-input/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-10 diff --git a/openspec/changes/phase-3d-client-display-input/design.md b/openspec/changes/phase-3d-client-display-input/design.md new file mode 100644 index 0000000..87c5511 --- /dev/null +++ b/openspec/changes/phase-3d-client-display-input/design.md @@ -0,0 +1,54 @@ +## Context + +The JSON control schema is the generated-binding authority for broker requests +and manifests, while VGI1 is the provider-neutral input payload. Both contracts +are strict: old decoders reject unknown response fields and old gateways reject +unknown VGI kinds. Phase 3D therefore needs optional fields plus explicit +feature negotiation rather than a wire-version or protobuf change. + +## Goals / Non-Goals + +**Goals:** + +- Preserve legacy JSON shapes when display negotiation is absent. +- Generate optional referenced objects correctly in Go, Rust, and Swift. +- Define exact, bounded, cross-language absolute-pointer and scroll bytes. + +**Non-Goals:** + +- Server clamp policy, database persistence, or provider translation. +- Live display renegotiation, provider packet exposure, or protobuf changes. + +## Decisions + +- Keep control wire version 1 and gate additions with exact feature IDs. This + avoids changing every legacy request while allowing strict clients to demand + the accepted display field. +- Use one reusable `DisplayMode` with the existing provider-policy dimension + bounds. Optional referenced objects become Go pointers so `omitempty` is real; + Rust and Swift retain their existing optional generation. +- Encode absolute pointer as four big-endian u16 values and scroll as two + big-endian i16 values in VGI1. Viewport coordinates are self-contained and + provider-neutral; provider scaling stays outside Protocol. +- Leave protobuf unchanged because the observed broker and VGI consumers use + JSON and fixed byte frames, not generated protobuf messages. + +## Risks / Trade-offs + +- [Old strict consumers reject new response fields] → emit them only after + `display.request.v1` negotiation. +- [New VGI kinds reach an old or unadvertised gateway] → reject before provider + translation unless the matching input feature is active. +- [Generated Go optional values serialize as zero objects] → require pointers, + omission tests, and clean second generation. + +## Migration Plan + +Freeze a new never-reused Protocol RC after full verification. Server and Data +Plane then pin that exact tag together. Legacy sessions omit all new fields and +continue using existing input kinds. + +## Open Questions + +None at the Protocol boundary; policy clamp and Apollo translation remain +consumer-owned work. diff --git a/openspec/changes/phase-3d-client-display-input/proposal.md b/openspec/changes/phase-3d-client-display-input/proposal.md new file mode 100644 index 0000000..c6485b0 --- /dev/null +++ b/openspec/changes/phase-3d-client-display-input/proposal.md @@ -0,0 +1,38 @@ +## Why + +The Phase 3D native client must request a bounded display mode and send +provider-neutral absolute-pointer and high-resolution scroll input. The current +wire contract exposes only policy-selected stream dimensions and rejects both +new input kinds. + +## What Changes + +- Add one bounded `DisplayMode` object and optional display fields to session + request/session/manifest contracts while keeping control wire version 1. +- Register `display.request.v1`, `input.absolute.v1`, and `input.scroll.v1` so + every new behavior is explicitly negotiated. +- Extend VGI1 with exact absolute-pointer and signed scroll bodies while + preserving all existing kind encodings. +- Require a new immutable Protocol release candidate after deterministic + Go/Rust/Swift generation and conformance pass; existing tags remain unchanged. + +## Capabilities + +### New Capabilities + +- `session-display-mode`: Optional feature-gated requested and effective display + mode disclosure for native session creation and manifests. + +### Modified Capabilities + +- `gateway-input-feedback`: Add negotiated provider-neutral absolute-pointer and + high-resolution scroll kinds to the existing sequenced input grammar. + +## Impact + +- JSON Schema, feature registry, VGI frame documentation, fixed conformance + fixtures, generated Go/Rust/Swift bindings, and strict validators. +- Connection Server session persistence/allocation and Data Plane input + translation after consumers pin the immutable Protocol candidate. +- Protobuf remains unchanged because no observed consumer uses it for these + JSON control or VGI payload paths. diff --git a/openspec/changes/phase-3d-client-display-input/specs/gateway-input-feedback/spec.md b/openspec/changes/phase-3d-client-display-input/specs/gateway-input-feedback/spec.md new file mode 100644 index 0000000..e879759 --- /dev/null +++ b/openspec/changes/phase-3d-client-display-input/specs/gateway-input-feedback/spec.md @@ -0,0 +1,33 @@ +## MODIFIED Requirements + +### Requirement: Typed sequenced input envelope +The `input.sequenced.v1` payload SHALL begin with ASCII `VGI1`, a one-byte +event kind, and one-byte payload length. It SHALL contain exactly one bounded +keyboard, mouse-button, relative-mouse, UTF-8 scalar, controller-state, +absolute-mouse, or high-resolution-scroll event. Absolute mouse kind `0x06` +SHALL contain big-endian u16 x, y, viewport width, and viewport height, reject +zero viewports and coordinates outside the viewport, and require negotiated +`input.absolute.v1`. Scroll kind `0x07` SHALL contain big-endian signed i16 +vertical and horizontal deltas and require negotiated `input.scroll.v1`. False +keyboard/mouse state and zeroed controller state are explicit releases. +Unknown or unadvertised kinds, length mismatches, malformed UTF-8, unsupported +controller indices, and reserved fields SHALL be rejected before provider +translation. + +#### Scenario: Keyboard state change +- **WHEN** a client sends a valid keyboard press or release envelope +- **THEN** the gateway forwards the corresponding typed provider input on its + reliable keyboard channel and records the pressed state for cleanup. + +#### Scenario: Absolute pointer and scroll +- **WHEN** an advertised client sends an in-viewport absolute coordinate or + signed scroll delta with the exact body length +- **THEN** the gateway accepts the provider-neutral event without adding it to + pressed-state cleanup. + +#### Scenario: Invalid input envelope +- **WHEN** a client sends an unknown or unadvertised kind, invalid length, + malformed UTF-8 scalar, zero viewport, out-of-range coordinate, or nonzero + reserved field +- **THEN** the gateway rejects it without sending provider input or changing + pressed state. diff --git a/openspec/changes/phase-3d-client-display-input/specs/session-display-mode/spec.md b/openspec/changes/phase-3d-client-display-input/specs/session-display-mode/spec.md new file mode 100644 index 0000000..2a96810 --- /dev/null +++ b/openspec/changes/phase-3d-client-display-input/specs/session-display-mode/spec.md @@ -0,0 +1,35 @@ +## ADDED Requirements + +### Requirement: Bounded display mode contract +The Protocol SHALL define `DisplayMode` with required integer +`resolution_width` from 320 through 16384, `resolution_height` from 200 through +8640, and `fps` from 1 through 240, rejecting missing, unknown, or out-of-range +fields. + +#### Scenario: Valid display mode +- **WHEN** a client encodes a 2560 by 1440 display mode at 120 FPS +- **THEN** generated Go, Rust, and Swift bindings accept the same exact values. + +#### Scenario: Invalid display mode +- **WHEN** any dimension or FPS is outside its bound or an unknown field exists +- **THEN** strict Protocol validation rejects the object. + +### Requirement: Feature-gated optional display disclosure +`SessionRequest.requested_display_mode`, +`BrokerSession.requested_display_mode`, +`BrokerSession.effective_display_mode`, and `ManifestProfile.display_mode` +SHALL be optional references to `DisplayMode`. Go bindings SHALL use pointers +with JSON omission and Rust/Swift bindings SHALL use optionals. A client MUST +send the request only after `display.request.v1` negotiation, and a +display-aware manifest MUST contain the accepted mode. Legacy requests SHALL +produce legacy response shapes without these fields. + +#### Scenario: Legacy request remains unchanged +- **WHEN** a client omits `requested_display_mode` +- **THEN** encoding omits the field and compatible Server responses omit all + display-mode fields. + +#### Scenario: Display-aware request discloses acceptance +- **WHEN** a negotiated client sends a valid requested mode +- **THEN** session responses preserve the requested mode and the allocated + manifest includes the Server-accepted mode. diff --git a/openspec/changes/phase-3d-client-display-input/tasks.md b/openspec/changes/phase-3d-client-display-input/tasks.md new file mode 100644 index 0000000..3abf1c7 --- /dev/null +++ b/openspec/changes/phase-3d-client-display-input/tasks.md @@ -0,0 +1,17 @@ +## 1. Contract sources + +- [x] 1.1 Add failing Go omission/bounds tests and fixed absolute/scroll vectors. +- [x] 1.2 Add the bounded display schema, optional generated references, and feature registrations. +- [x] 1.3 Extend the VGI frame grammar and Python/Go/Rust/Swift classifiers without changing existing kinds. + +## 2. Deterministic verification + +- [x] 2.1 Regenerate Go/Rust/Swift bindings and pass focused Go, frame, source, binding, and strict-contract checks. +- [x] 2.2 Run the complete Protocol verifier with isolated compiler caches. +- [x] 2.3 Regenerate a second time, confirm zero drift, and inspect the exact source/generated diff. + +## 3. Freeze + +- [ ] 3.1 Commit the verified source and generated bindings as one unsigned capability-sized change. +- [ ] 3.2 Verify the next Phase 3D Protocol RC name is unused locally and remotely, then create one local immutable candidate tag without publishing it. +- [ ] 3.3 Reconcile and archive this OpenSpec change after the immutable candidate is frozen. diff --git a/registries/features.json b/registries/features.json index 7601b41..e4712b7 100644 --- a/registries/features.json +++ b/registries/features.json @@ -3,6 +3,9 @@ "features": [ {"id": "control.v1", "direction": "bidirectional", "max_frame_bytes": 65536}, {"id": "events.resume.v1", "direction": "server-to-client", "max_frame_bytes": 65536}, - {"id": "clipboard.text.v1", "direction": "bidirectional", "max_frame_bytes": 65536} + {"id": "clipboard.text.v1", "direction": "bidirectional", "max_frame_bytes": 65536}, + {"id": "display.request.v1", "direction": "client-to-server", "max_frame_bytes": 65536}, + {"id": "input.absolute.v1", "direction": "client-to-server", "max_frame_bytes": 14}, + {"id": "input.scroll.v1", "direction": "client-to-server", "max_frame_bytes": 10} ] } diff --git a/schemas/control-v1.schema.json b/schemas/control-v1.schema.json index 2d79078..c899729 100644 --- a/schemas/control-v1.schema.json +++ b/schemas/control-v1.schema.json @@ -178,6 +178,16 @@ "refresh_expires_at": {"type": "string", "format": "date-time", "maxLength": 64} } }, + "DisplayMode": { + "type": "object", + "additionalProperties": false, + "required": ["resolution_width", "resolution_height", "fps"], + "properties": { + "resolution_width": {"type": "integer", "minimum": 320, "maximum": 16384}, + "resolution_height": {"type": "integer", "minimum": 200, "maximum": 8640}, + "fps": {"type": "integer", "minimum": 1, "maximum": 240} + } + }, "SessionRequest": { "type": "object", "additionalProperties": false, @@ -187,7 +197,8 @@ "device_key_id": {"type": "string", "minLength": 1, "maxLength": 128}, "pool_id": {"type": "string", "minLength": 1, "maxLength": 128}, "idempotency_key": {"type": "string", "minLength": 1, "maxLength": 256}, - "policy_snapshot": {"$ref": "#/$defs/AllocationPolicy"} + "policy_snapshot": {"$ref": "#/$defs/AllocationPolicy"}, + "requested_display_mode": {"$ref": "#/$defs/DisplayMode"} } }, "ReconnectRequest": { @@ -219,7 +230,9 @@ "correlation_id": {"type": "string", "minLength": 1, "maxLength": 128}, "requested_at": {"type": "string", "format": "date-time", "maxLength": 64}, "ended_at": {"type": "string", "format": "date-time", "maxLength": 64}, - "version": {"type": "integer", "minimum": 1} + "version": {"type": "integer", "minimum": 1}, + "requested_display_mode": {"$ref": "#/$defs/DisplayMode"}, + "effective_display_mode": {"$ref": "#/$defs/DisplayMode"} } }, "AllocationPolicy": { @@ -279,7 +292,8 @@ "required": ["id", "bounds"], "properties": { "id": {"type": "string", "minLength": 1, "maxLength": 128}, - "bounds": {"$ref": "#/$defs/ManifestBounds"} + "bounds": {"$ref": "#/$defs/ManifestBounds"}, + "display_mode": {"$ref": "#/$defs/DisplayMode"} } }, "ManifestBounds": { diff --git a/tests/go/protocol_test.go b/tests/go/protocol_test.go index da9771b..effb479 100644 --- a/tests/go/protocol_test.go +++ b/tests/go/protocol_test.go @@ -1,6 +1,7 @@ package protocol_test import ( + "bytes" "reflect" "strings" "testing" @@ -22,6 +23,83 @@ func TestManifestRejectsForbiddenAndUnknownFields(t *testing.T) { } } +func TestDisplayModeIsOptionalStrictAndBounded(t *testing.T) { + displayType := reflect.TypeOf(protocol.DisplayMode{}) + for _, field := range []struct { + owner reflect.Type + name string + }{ + {reflect.TypeOf(protocol.SessionRequest{}), "RequestedDisplayMode"}, + {reflect.TypeOf(protocol.BrokerSession{}), "RequestedDisplayMode"}, + {reflect.TypeOf(protocol.BrokerSession{}), "EffectiveDisplayMode"}, + {reflect.TypeOf(protocol.ManifestProfile{}), "DisplayMode"}, + } { + actual, ok := field.owner.FieldByName(field.name) + if !ok || actual.Type.Kind() != reflect.Pointer || actual.Type.Elem() != displayType { + t.Fatalf("%s.%s must be *DisplayMode, got %v", field.owner, field.name, actual.Type) + } + } + + request := protocol.SessionRequest{ + ClientDeviceID: "device-1", DeviceKeyID: "key-1", PoolID: "pool-1", IdempotencyKey: "request-1", + PolicySnapshot: protocol.AllocationPolicy{ + MinimumKbps: 1000, TargetKbps: 2000, MaximumKbps: 3000, Tier: "standard", + Audience: "versevdi-gateway", Protocol: "verse", ProtocolVersion: 1, + GrantTTLSeconds: 60, ReservationLeaseSeconds: 300, + }, + } + legacy, err := protocol.EncodeSessionRequest(request) + if err != nil { + t.Fatalf("EncodeSessionRequest legacy request: %v", err) + } + if bytes.Contains(legacy, []byte("requested_display_mode")) { + t.Fatalf("legacy request disclosed an absent display mode: %s", legacy) + } + + mode := protocol.DisplayMode{ResolutionWidth: 2560, ResolutionHeight: 1440, Fps: 120} + request.RequestedDisplayMode = &mode + aware, err := protocol.EncodeSessionRequest(request) + if err != nil || !bytes.Contains(aware, []byte(`"requested_display_mode":{"resolution_width":2560,"resolution_height":1440,"fps":120}`)) { + t.Fatalf("display-aware request = %s, err = %v", aware, err) + } + decoded, err := protocol.DecodeSessionRequest(aware) + if err != nil || decoded.RequestedDisplayMode == nil || *decoded.RequestedDisplayMode != mode { + t.Fatalf("DecodeSessionRequest display mode = %+v, err = %v", decoded.RequestedDisplayMode, err) + } + nullMode := bytes.Replace(aware, []byte(`{"resolution_width":2560,"resolution_height":1440,"fps":120}`), []byte("null"), 1) + if _, err := protocol.DecodeSessionRequest(nullMode); err == nil { + t.Fatalf("DecodeSessionRequest accepted explicit null display mode: %s", nullMode) + } + + profile := protocol.ManifestProfile{ + ID: "standard", + Bounds: protocol.ManifestBounds{MinimumKbps: 1000, TargetKbps: 2000, MaximumKbps: 3000}, + } + legacyProfile, err := protocol.EncodeManifestProfile(profile) + if err != nil || bytes.Contains(legacyProfile, []byte("display_mode")) { + t.Fatalf("legacy profile = %s, err = %v", legacyProfile, err) + } + profile.DisplayMode = &mode + awareProfile, err := protocol.EncodeManifestProfile(profile) + if err != nil || !bytes.Contains(awareProfile, []byte(`"display_mode":{"resolution_width":2560,"resolution_height":1440,"fps":120}`)) { + t.Fatalf("display-aware profile = %s, err = %v", awareProfile, err) + } + + for _, invalid := range []string{ + `{"resolution_width":319,"resolution_height":1440,"fps":120}`, + `{"resolution_width":16385,"resolution_height":1440,"fps":120}`, + `{"resolution_width":2560,"resolution_height":199,"fps":120}`, + `{"resolution_width":2560,"resolution_height":8641,"fps":120}`, + `{"resolution_width":2560,"resolution_height":1440,"fps":0}`, + `{"resolution_width":2560,"resolution_height":1440,"fps":241}`, + `{"resolution_width":2560,"resolution_height":1440,"fps":120,"unknown":true}`, + } { + if _, err := protocol.DecodeDisplayMode([]byte(invalid)); err == nil { + t.Fatalf("DecodeDisplayMode accepted %s", invalid) + } + } +} + func TestPageInfoRejectsOutOfBoundsLimit(t *testing.T) { if _, err := protocol.DecodePageInfo([]byte(`{"limit":101,"next_cursor":""}`)); err == nil { t.Fatal("DecodePageInfo accepted limit above the contract maximum") diff --git a/tools/generate.py b/tools/generate.py index 8f98dc6..4e80f5b 100644 --- a/tools/generate.py +++ b/tools/generate.py @@ -160,7 +160,11 @@ def go_validation(definition: dict[str, Any]) -> list[str]: lines.append(f"\tfor index := range v.{field} {{ if err := v.{field}[index].Validate(); err != nil {{ violations = append(violations, FieldViolation{{Field: fmt.Sprintf(\"{prop_name}[%d]\", index), Code: \"invalid_item\"}}) }} }}") reference = ref_name(prop) if reference: - lines.append(f"\tif err := v.{field}.Validate(); err != nil {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"invalid_object\"}}) }}") + validation = f"if err := v.{field}.Validate(); err != nil {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"invalid_object\"}}) }}" + if prop_name not in required: + lines.append(f"\tif v.{field} != nil {{ {validation} }}") + else: + lines.append(f"\t{validation}") if name in {"AllocationPolicy", "ManifestBounds"}: lines.append("\tif v.MinimumKbps > v.TargetKbps || v.TargetKbps > v.MaximumKbps { violations = append(violations, FieldViolation{Field: \"bounds\", Code: \"invalid_order\"}) }") if name == "GatewayRegistration": @@ -212,7 +216,10 @@ def generate_go(defs: dict[str, dict[str, Any]], schema_hash: str, version: str, required = set(definition.get("required", [])) for prop_name, prop in definition.get("properties", {}).items(): tag = prop_name + (",omitempty" if prop_name not in required else "") - out.append(f"\t{go_field(prop_name)} {prop_type(prop, 'go')} `json:\"{tag}\"`") + typ = prop_type(prop, "go") + if prop_name not in required and ref_name(prop): + typ = "*" + typ + out.append(f"\t{go_field(prop_name)} {typ} `json:\"{tag}\"`") out.extend(["}", ""]) for name in sorted(defs): out.append(f"func (v {name}) Validate() error {{") @@ -234,6 +241,11 @@ def generate_go(defs: dict[str, dict[str, Any]], schema_hash: str, version: str, % (prop_name, prop_name) ) for prop_name, prop in defs[name].get("properties", {}).items(): + if prop_name not in required_fields and ref_name(prop): + out.append( + '\tif raw, ok := fields["%s"]; ok && bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { return value, ValidationError{Violations: []FieldViolation{{Field: "%s", Code: "invalid_object"}}} }' + % (prop_name, prop_name) + ) if "x-max-bytes" in prop and prop.get("type") != "string": out.append( '\tif raw, ok := fields["%s"]; ok && len(raw) > %d { return value, ValidationError{Violations: []FieldViolation{{Field: "%s", Code: "max_bytes"}}} }' @@ -603,6 +615,8 @@ def generate_swift(defs: dict[str, dict[str, Any]], schema_hash: str, compatibil typ = swift_type(prop) if prop_name in required: decoded.append(f"{field}: try c.decode({typ}.self, forKey: .{field})") + elif ref_name(prop): + decoded.append(f"{field}: try c.contains(.{field}) ? c.decode({typ}.self, forKey: .{field}) : nil") else: decoded.append(f"{field}: try c.decodeIfPresent({typ}.self, forKey: .{field})") out.append(f" try self.init({', '.join(decoded)})") diff --git a/tools/go-conformance/main.go b/tools/go-conformance/main.go index 93eba83..9acac0b 100644 --- a/tools/go-conformance/main.go +++ b/tools/go-conformance/main.go @@ -125,10 +125,12 @@ func evaluate(kind, input string) string { } return "valid" case "tunnel": - if (parts["offered"] == "1" || parts["offered"] == "0" || parts["offered"] == "-1") && parts["feature"] == "control.v1" { + feature := parts["feature"] + registered := feature == "control.v1" || feature == "display.request.v1" || feature == "input.absolute.v1" || feature == "input.scroll.v1" + if (parts["offered"] == "1" || parts["offered"] == "0" || parts["offered"] == "-1") && registered { return "valid" } - if parts["feature"] != "control.v1" { + if !registered { return "invalid:unsupported_feature" } return "invalid:unsupported_version" @@ -226,6 +228,19 @@ func classifyGatewayInput(encoded string) string { } } } + case 6: + if len(body) != 8 { + return "invalid:length" + } + x, y := uint16(body[0])<<8|uint16(body[1]), uint16(body[2])<<8|uint16(body[3]) + width, height := uint16(body[4])<<8|uint16(body[5]), uint16(body[6])<<8|uint16(body[7]) + if width == 0 || height == 0 || x >= width || y >= height { + return "invalid:field" + } + case 7: + if len(body) != 4 { + return "invalid:length" + } default: return "invalid:kind" } diff --git a/tools/native_conformance.rs b/tools/native_conformance.rs index ce14da5..d9391c2 100644 --- a/tools/native_conformance.rs +++ b/tools/native_conformance.rs @@ -47,8 +47,8 @@ fn evaluate(kind: &str, input: &str) -> &'static str { || !values.contains_key("correlation_id") => "invalid:required", "event" => "valid", "tunnel" if matches!(values.get("offered").map(String::as_str), Some("1") | Some("0") | Some("-1")) - && values.get("feature").map(String::as_str) == Some("control.v1") => "valid", - "tunnel" if values.get("feature").map(String::as_str) != Some("control.v1") => { + && matches!(values.get("feature").map(String::as_str), Some("control.v1") | Some("display.request.v1") | Some("input.absolute.v1") | Some("input.scroll.v1")) => "valid", + "tunnel" if !matches!(values.get("feature").map(String::as_str), Some("control.v1") | Some("display.request.v1") | Some("input.absolute.v1") | Some("input.scroll.v1")) => { "invalid:unsupported_feature" } "tunnel" => "invalid:unsupported_version", @@ -110,6 +110,16 @@ fn classify_gateway_input(encoded: &str) -> &'static str { 5 if body[0] > 15 => "invalid:field", 5 if body[1] == 0 && body[2] == 0 && body[3..].iter().any(|value| *value != 0) => "invalid:field", 5 => "valid", + 6 if body.len() != 8 => "invalid:length", + 6 => { + let x = u16::from_be_bytes([body[0], body[1]]); + let y = u16::from_be_bytes([body[2], body[3]]); + let width = u16::from_be_bytes([body[4], body[5]]); + let height = u16::from_be_bytes([body[6], body[7]]); + if width != 0 && height != 0 && x < width && y < height { "valid" } else { "invalid:field" } + } + 7 if body.len() == 4 => "valid", + 7 => "invalid:length", _ => "invalid:kind", } } diff --git a/tools/native_conformance.swift b/tools/native_conformance.swift index b73512b..72e80d0 100644 --- a/tools/native_conformance.swift +++ b/tools/native_conformance.swift @@ -27,8 +27,9 @@ func evaluate(_ kind: String, _ input: String) -> String { guard let sequence = Int(values["sequence"] ?? ""), sequence > 0, values["correlation_id"] != nil else { return "invalid:required" } return "valid" case "tunnel": - if ["1", "0", "-1"].contains(values["offered"] ?? "") && values["feature"] == "control.v1" { return "valid" } - return values["feature"] == "control.v1" ? "invalid:unsupported_version" : "invalid:unsupported_feature" + let registered = ["control.v1", "display.request.v1", "input.absolute.v1", "input.scroll.v1"].contains(values["feature"] ?? "") + if ["1", "0", "-1"].contains(values["offered"] ?? "") && registered { return "valid" } + return registered ? "invalid:unsupported_version" : "invalid:unsupported_feature" case "datagram": return classifyDatagram(values["hex"] ?? "") case "gateway_input": return classifyGatewayInput(values["hex"] ?? "") case "gateway_feedback": return classifyGatewayFeedback(values["hex"] ?? "") @@ -82,6 +83,14 @@ func classifyGatewayInput(_ encoded: String) -> String { guard body[0] <= 15 else { return "invalid:field" } guard body[1] != 0 || body[2] != 0 || body.dropFirst(3).allSatisfy({ $0 == 0 }) else { return "invalid:field" } return "valid" + case 6: + guard body.count == 8 else { return "invalid:length" } + let x = Int(body[0]) * 256 + Int(body[1]) + let y = Int(body[2]) * 256 + Int(body[3]) + let width = Int(body[4]) * 256 + Int(body[5]) + let height = Int(body[6]) * 256 + Int(body[7]) + return width > 0 && height > 0 && x < width && y < height ? "valid" : "invalid:field" + case 7: return body.count == 4 ? "valid" : "invalid:length" default: return "invalid:kind" } } diff --git a/tools/test_generated_contracts.py b/tools/test_generated_contracts.py index 3e7e562..0b9aee9 100644 --- a/tools/test_generated_contracts.py +++ b/tools/test_generated_contracts.py @@ -103,6 +103,44 @@ do { ) fatalError("invalid allocation bounds were accepted") } catch { } +let displayMode = try DisplayMode(resolutionWidth: 2560, resolutionHeight: 1440, fps: 120) +for invalid in [ + { try DisplayMode(resolutionWidth: 319, resolutionHeight: 1440, fps: 120) }, + { try DisplayMode(resolutionWidth: 2560, resolutionHeight: 199, fps: 120) }, + { try DisplayMode(resolutionWidth: 2560, resolutionHeight: 1440, fps: 241) }, +] { + do { + _ = try invalid() + fatalError("invalid display mode was accepted") + } catch { } +} +let allocationPolicy = try AllocationPolicy( + minimumKbps: 1000, targetKbps: 2000, maximumKbps: 3000, tier: "standard", + audience: "versevdi-gateway", protocolValue: "verse", protocolVersion: 1, + grantTtlSeconds: 60, reservationLeaseSeconds: 300 +) +let legacyDisplayRequest = try SessionRequest( + clientDeviceId: "device-1", deviceKeyId: "key-1", poolId: "pool-1", + idempotencyKey: "request-1", policySnapshot: allocationPolicy, + requestedDisplayMode: nil +).encodeJSON() +guard !String(data: legacyDisplayRequest, encoding: .utf8)!.contains("requested_display_mode") else { + fatalError("legacy request encoded an absent display mode") +} +let displayRequest = try SessionRequest( + clientDeviceId: "device-1", deviceKeyId: "key-1", poolId: "pool-1", + idempotencyKey: "request-1", policySnapshot: allocationPolicy, + requestedDisplayMode: displayMode +) +guard try SessionRequest.decodeJSON(displayRequest.encodeJSON()).requestedDisplayMode == displayMode else { + fatalError("display mode did not round-trip") +} +var nullDisplayRequest = try JSONSerialization.jsonObject(with: displayRequest.encodeJSON()) as! [String: Any] +nullDisplayRequest["requested_display_mode"] = NSNull() +do { + _ = try SessionRequest.decodeJSON(try JSONSerialization.data(withJSONObject: nullDisplayRequest)) + fatalError("explicit null display mode was accepted") +} catch { } let streamPolicy = try ProviderStreamPolicy( resolutionWidth: 2560, resolutionHeight: 1440, fps: 120, codec: "HEVC", bitrateKbps: 40000, audioEnabled: true @@ -218,6 +256,24 @@ fn main() { assert!(AllocationPolicy::new( 100, 50, 25, "standard".into(), "audience".into(), "verse".into(), 1, 60, 300, ).is_err()); + let display_mode = DisplayMode::new(2560, 1440, 120).unwrap(); + assert!(DisplayMode::new(319, 1440, 120).is_err()); + assert!(DisplayMode::new(2560, 199, 120).is_err()); + assert!(DisplayMode::new(2560, 1440, 241).is_err()); + let allocation_policy = AllocationPolicy::new( + 1000, 2000, 3000, "standard".into(), "versevdi-gateway".into(), + "verse".into(), 1, 60, 300, + ).unwrap(); + let legacy_display_request = SessionRequest::new( + "device-1".into(), "key-1".into(), "pool-1".into(), "request-1".into(), + allocation_policy.clone(), None, + ).unwrap(); + assert!(legacy_display_request.requestedDisplayMode().is_none()); + let display_request = SessionRequest::new( + "device-1".into(), "key-1".into(), "pool-1".into(), "request-1".into(), + allocation_policy, Some(display_mode.clone()), + ).unwrap(); + assert_eq!(display_request.requestedDisplayMode(), &Some(display_mode)); assert!(ProviderStreamPolicy::new( 2560, 1440, 120, "HEVC".into(), 40000, true, ).is_ok()); diff --git a/tools/validate.py b/tools/validate.py index 5344fc0..3feeb97 100644 --- a/tools/validate.py +++ b/tools/validate.py @@ -36,6 +36,24 @@ def main() -> int: maximum = entry.get("max_frame_bytes", entry.get("max_payload_bytes")) assert isinstance(maximum, int) and 1 <= maximum <= maximum_bound + feature_registry = json.loads((ROOT / "registries/features.json").read_text(encoding="utf-8")) + registered_features = {entry["id"] for entry in feature_registry["features"]} + assert {"display.request.v1", "input.absolute.v1", "input.scroll.v1"}.issubset(registered_features) + + display_mode = defs["DisplayMode"] + assert display_mode["required"] == ["resolution_width", "resolution_height", "fps"] + assert display_mode["properties"]["resolution_width"] == {"type": "integer", "minimum": 320, "maximum": 16384} + assert display_mode["properties"]["resolution_height"] == {"type": "integer", "minimum": 200, "maximum": 8640} + assert display_mode["properties"]["fps"] == {"type": "integer", "minimum": 1, "maximum": 240} + for owner, field in ( + ("SessionRequest", "requested_display_mode"), + ("BrokerSession", "requested_display_mode"), + ("BrokerSession", "effective_display_mode"), + ("ManifestProfile", "display_mode"), + ): + assert field not in defs[owner]["required"] + assert defs[owner]["properties"][field] == {"$ref": "#/$defs/DisplayMode"} + manifest = json.loads((ROOT / "fixtures/valid/manifest.json").read_text(encoding="utf-8")) assert set(manifest).issubset(set(defs["ConnectionManifest"]["properties"])) forbidden = json.loads((ROOT / "fixtures/invalid/manifest-provider-field.json").read_text(encoding="utf-8")) diff --git a/tools/validate_gateway_envelopes.py b/tools/validate_gateway_envelopes.py index a7bb4ea..2d830bb 100644 --- a/tools/validate_gateway_envelopes.py +++ b/tools/validate_gateway_envelopes.py @@ -37,6 +37,13 @@ def classify_input(raw: bytes) -> str: return "invalid:field" active_mask = int.from_bytes(body[1:3], "big") return "valid" if active_mask or not any(body[3:]) else "invalid:field" + if kind == 6: + if len(body) != 8: + return "invalid:length" + x, y, width, height = (int.from_bytes(body[index:index + 2], "big") for index in range(0, 8, 2)) + return "valid" if width and height and x < width and y < height else "invalid:field" + if kind == 7: + return "valid" if len(body) == 4 else "invalid:length" return "invalid:kind"